git.lucas.co / cce-mail
mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git

commit15a59ba28e8df62fb2a3b7535b8689bc1349b65e
parent131171bf03
authorLucas Galante <[email protected]>
date2026-08-22 14:13
feat: drafts get Edit/Delete in the card menu

Reply and Mark Read/Unread are meaningless on a draft — there is nobody
to reply to and no read state — so a draft card offers Edit and Delete.
Edit is SelectEmail, the same path a left-click takes to pull the draft
back into the compose dialog.

The menu's rows now carry their messages in a parallel actions vec built
beside the labels, replacing the positional index map: with two menu
shapes, a map keyed on row number is one reordering away from firing the
wrong action.

Shadow-verified on a seeded draft: menu shows subject + Edit + Delete,
Edit reopens it in compose with To/Subject/Body intact and takes it out
of Drafts, Delete files it to Trash, and inbox cards still get the full
Reply/Delete/Mark Unread set.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/main.rs | 58 +++++++++++++++++++++++++++++++++++++++-------------------
 1 file changed, 39 insertions(+), 19 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index cc6b0dc..88e51b1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -212,6 +212,9 @@ struct ClearEmailApp {
     /// un-clobbered — [`Self::split_geom`] clamps at use, not here.
     list_w: f32,
     split_dragging: bool,
+    /// Message per row of the open card context menu, built beside its
+    /// labels — index 0 is the inert subject header, hence Option.
+    context_menu_actions: Vec<Option<AppMessage>>,
     compose_open: bool,
     compose_title: String,
     /// When the most recent IMAP sync was spawned — folder switches re-sync
@@ -1959,10 +1962,10 @@ impl ClearEmailApp {
             bx > -9000.0 && px >= bx && px <= bx + bw && py >= by && py <= by + bh
         });
         let Some(idx) = hit else { return false };
-        let Some((id, read, subject)) = self
+        let Some((id, read, subject, is_draft)) = self
             .filtered_emails()
             .get(idx)
-            .map(|e| (e.id, e.read, e.subject.clone()))
+            .map(|e| (e.id, e.read, e.subject.clone(), e.folder == "drafts"))
         else {
             return false;
         };
@@ -1974,13 +1977,30 @@ impl ClearEmailApp {
         self.selected_email_id = Some(id);
         self.body_scroll = 0.0;
 
-        // Row 0 is the subject header (header_count = 1) — dimmed and inert.
-        let options = vec![
-            ellipsize(&subject, 28),
-            "Reply".to_string(),
-            "Delete".to_string(),
-            if read { "Mark Unread" } else { "Mark Read" }.to_string(),
-        ];
+        // Row 0 is the subject header (header_count = 1) — dimmed and inert,
+        // hence the leading None. A draft is not a message that arrived:
+        // there is nobody to reply to and no read state, so it gets Edit —
+        // which is SelectEmail, the same path a left-click takes to resume it
+        // in the compose dialog.
+        let (labels, actions): (Vec<&str>, Vec<Option<AppMessage>>) = if is_draft {
+            (
+                vec!["Edit", "Delete"],
+                vec![Some(AppMessage::SelectEmail(id)), Some(AppMessage::DeleteSelected)],
+            )
+        } else {
+            (
+                vec!["Reply", "Delete", if read { "Mark Unread" } else { "Mark Read" }],
+                vec![
+                    Some(AppMessage::Reply),
+                    Some(AppMessage::DeleteSelected),
+                    Some(AppMessage::ToggleUnread),
+                ],
+            )
+        };
+        let mut options = vec![ellipsize(&subject, 28)];
+        options.extend(labels.into_iter().map(str::to_string));
+        self.context_menu_actions = std::iter::once(None).chain(actions).collect();
+
         let target = self.email_buttons[idx].id();
         cce_ui::widget::context_menu::show(px, py, options.clone(), 1, target);
 
@@ -1999,10 +2019,12 @@ impl ClearEmailApp {
     }
 
     /// A press while a card menu is open: an item fires its action, anything
-    /// else only dismisses. The toolkit's own `context_menu::mouse_input` is
-    /// deliberately NOT used — it dispatches labels through a fixed map into
-    /// the target widget, where "Delete" means DeleteKey, not this app's
-    /// message.
+    /// else only dismisses. The row's message comes from the parallel
+    /// `context_menu_actions` built with the labels, so the two menu shapes
+    /// (mail vs draft) can't drift out of step with a positional map. The
+    /// toolkit's own `context_menu::mouse_input` is deliberately NOT used —
+    /// it dispatches labels through a fixed map into the target widget,
+    /// where "Delete" means DeleteKey, not this app's message.
     fn context_menu_press(&mut self, px: f32, py: f32) -> Option<AppMessage> {
         let mx = cce_ui::widget::context_menu::x();
         let my = cce_ui::widget::context_menu::y();
@@ -2010,14 +2032,11 @@ impl ClearEmailApp {
         let mh = cce_ui::widget::context_menu::h();
         let mut msg = None;
         if px >= mx && px <= mx + mw && py >= my && py <= my + mh {
-            msg = match ((py - my) / CONTEXT_ROW_H) as usize {
-                1 => Some(AppMessage::Reply),
-                2 => Some(AppMessage::DeleteSelected),
-                3 => Some(AppMessage::ToggleUnread),
-                _ => None,
-            };
+            let row = ((py - my) / CONTEXT_ROW_H) as usize;
+            msg = self.context_menu_actions.get(row).cloned().flatten();
         }
         cce_ui::widget::context_menu::hide();
+        self.context_menu_actions.clear();
         msg
     }
 
@@ -2441,6 +2460,7 @@ impl Application for ClearEmailApp {
             body_sb_drag_offset: 0.0,
             list_w: load_list_w().unwrap_or(LIST_W_DEFAULT),
             split_dragging: false,
+            context_menu_actions: Vec::new(),
             compose_open,
             compose_title,
             status_message: None,