diff --git a/apps/web/src/features/compose/useComposeSession.ts b/apps/web/src/features/compose/useComposeSession.ts index 5e9e84be..18f9c035 100644 --- a/apps/web/src/features/compose/useComposeSession.ts +++ b/apps/web/src/features/compose/useComposeSession.ts @@ -316,7 +316,10 @@ export function useComposeSession( const current = draftRef.current; if (!current) throw new Error("No draft is open"); const now = new Date().toISOString(); - const draftId = crypto.randomUUID(); + // Editing an existing stored draft must save it in place; only mint a + // fresh id for a genuinely new compose session (save-local is an + // upsert-by-id, so reusing the id updates rather than duplicates it). + const draftId = intent.draftId ?? crypto.randomUUID(); await saveLocalDraft({ id: draftId, account_id: current.accountId, diff --git a/crates/compose/src/draft_codec.rs b/crates/compose/src/draft_codec.rs new file mode 100644 index 00000000..ee582b15 --- /dev/null +++ b/crates/compose/src/draft_codec.rs @@ -0,0 +1,194 @@ +//! Bidirectional codec between a stored [`Draft`] and the editor-facing +//! compose-file format (YAML frontmatter + markdown body). +//! +//! Before this module the mapping was open-coded in the daemon, web, and TUI +//! crates (each with its own `format_addresses` and inline `ComposeFrontmatter` +//! construction). Centralizing it here lets every surface load a stored draft +//! into `$EDITOR` and round-trip the edit back to the *same* [`DraftId`] — the +//! basis for editing a draft in place instead of creating a new one and +//! discarding the old. +//! +//! [`DraftId`]: mxr_core::id::DraftId + +use crate::frontmatter::{ + parse_compose_file, render_compose_file, ComposeError, ComposeFrontmatter, +}; +use chrono::{DateTime, Utc}; +use mxr_core::types::{Address, Draft, ReplyHeaders}; +use std::path::PathBuf; + +/// Render a list of addresses back into a `"Name , email"` header +/// string suitable for a compose-file `to:`/`cc:`/`bcc:` field. +pub fn format_addresses(addresses: &[Address]) -> String { + addresses + .iter() + .map(|address| match address.name.as_deref() { + Some(name) if !name.trim().is_empty() => format!("{name} <{}>", address.email), + _ => address.email.clone(), + }) + .collect::>() + .join(", ") +} + +/// Build editor frontmatter from a stored draft. `from` is the sending +/// account's address — the [`Draft`] stores only an `account_id`, so the +/// caller resolves the email and passes it in. +pub fn frontmatter_from_draft(draft: &Draft, from: &str) -> ComposeFrontmatter { + ComposeFrontmatter { + to: format_addresses(&draft.to), + cc: format_addresses(&draft.cc), + bcc: format_addresses(&draft.bcc), + subject: draft.subject.clone(), + from: from.to_string(), + in_reply_to: draft + .reply_headers + .as_ref() + .map(|headers| headers.in_reply_to.clone()), + intent: draft.intent, + references: draft + .reply_headers + .as_ref() + .map(|headers| headers.references.clone()) + .unwrap_or_default(), + thread_id: draft + .reply_headers + .as_ref() + .and_then(|headers| headers.thread_id.clone()), + attach: draft + .attachments + .iter() + .map(|attachment| attachment.display().to_string()) + .collect(), + signature: None, + } +} + +/// Render a stored draft into the editor-facing compose-file text +/// (frontmatter + markdown body). No context block: an edit reopens the +/// user's own content, not a quoted original. +pub fn draft_to_compose_file(draft: &Draft, from: &str) -> Result { + let frontmatter = frontmatter_from_draft(draft, from); + render_compose_file(&frontmatter, &draft.body_markdown, None) +} + +/// Re-assemble an edited compose file back into a [`Draft`], preserving the +/// original `id`, `account_id`, `created_at`, and `inline_calendar_reply`, +/// and stamping `updated_at`. This is what makes "edit in place" possible: +/// the same [`DraftId`] round-trips instead of a new one being minted. +/// +/// [`DraftId`]: mxr_core::id::DraftId +pub fn apply_edited_compose_file( + existing: &Draft, + content: &str, + updated_at: DateTime, +) -> Result { + let (frontmatter, body) = parse_compose_file(content)?; + let reply_headers = frontmatter + .in_reply_to + .as_ref() + .map(|in_reply_to| ReplyHeaders { + in_reply_to: in_reply_to.clone(), + references: frontmatter.references.clone(), + thread_id: frontmatter.thread_id.clone(), + }) + .or_else(|| existing.reply_headers.clone()); + + Ok(Draft { + id: existing.id.clone(), + account_id: existing.account_id.clone(), + reply_headers, + // A user who clears `intent:` back to the default keeps the draft's + // original intent rather than silently downgrading a reply to a new. + intent: if frontmatter.intent == mxr_core::DraftIntent::New { + existing.intent + } else { + frontmatter.intent + }, + to: mxr_mail_parse::parse_address_list(&frontmatter.to), + cc: mxr_mail_parse::parse_address_list(&frontmatter.cc), + bcc: mxr_mail_parse::parse_address_list(&frontmatter.bcc), + subject: frontmatter.subject, + body_markdown: body, + attachments: frontmatter.attach.iter().map(PathBuf::from).collect(), + inline_calendar_reply: existing.inline_calendar_reply.clone(), + created_at: existing.created_at, + updated_at, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use mxr_core::id::{AccountId, DraftId}; + use mxr_core::types::DraftIntent; + + fn sample_draft() -> Draft { + Draft { + id: DraftId::new(), + account_id: AccountId::new(), + reply_headers: None, + intent: DraftIntent::New, + to: vec![ + Address { + name: Some("Alice Example".into()), + email: "alice@example.com".into(), + }, + Address { + name: None, + email: "bob@example.com".into(), + }, + ], + cc: vec![], + bcc: vec![], + subject: "Q4 plan".into(), + body_markdown: "Body line one.\n\nBody line two.".into(), + attachments: vec![], + inline_calendar_reply: None, + created_at: DateTime::from_timestamp(1_700_000_000, 0).unwrap(), + updated_at: DateTime::from_timestamp(1_700_000_000, 0).unwrap(), + } + } + + #[test] + fn format_addresses_renders_name_and_bare() { + let rendered = format_addresses(&sample_draft().to); + assert_eq!( + rendered, + "Alice Example , bob@example.com" + ); + } + + #[test] + fn round_trip_preserves_id_created_at_and_content() { + let original = sample_draft(); + let file = draft_to_compose_file(&original, "me@example.com").unwrap(); + assert!(file.contains("subject: Q4 plan")); + assert!(file.contains("from: me@example.com")); + assert!(file.contains("Body line one.")); + + let edited = file.replace("Q4 plan", "Q4 plan (revised)"); + let later = DateTime::from_timestamp(1_700_000_999, 0).unwrap(); + let result = apply_edited_compose_file(&original, &edited, later).unwrap(); + + // Identity and creation time survive the edit; only content + updated_at change. + assert_eq!(result.id, original.id); + assert_eq!(result.account_id, original.account_id); + assert_eq!(result.created_at, original.created_at); + assert_eq!(result.updated_at, later); + assert_eq!(result.subject, "Q4 plan (revised)"); + assert_eq!(result.to.len(), 2); + assert_eq!(result.to[0].email, "alice@example.com"); + } + + #[test] + fn absent_intent_falls_back_to_existing() { + let mut original = sample_draft(); + original.intent = DraftIntent::Reply; + // A compose file with no `intent:` line — the user never touched it, so + // the parsed frontmatter defaults to New and must fall back to Reply. + let file = "---\nto: a@example.com\nsubject: Hi\nfrom: me@example.com\n---\n\nBody."; + let now = DateTime::from_timestamp(1_700_000_500, 0).unwrap(); + let result = apply_edited_compose_file(&original, file, now).unwrap(); + assert_eq!(result.intent, DraftIntent::Reply); + } +} diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index db686756..aff44e90 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -7,6 +7,7 @@ )] pub mod attachments; +pub mod draft_codec; pub mod editor; pub mod email; pub mod frontmatter; diff --git a/crates/daemon/src/activity/mapper.rs b/crates/daemon/src/activity/mapper.rs index 2a63adce..92f927db 100644 --- a/crates/daemon/src/activity/mapper.rs +++ b/crates/daemon/src/activity/mapper.rs @@ -269,6 +269,12 @@ pub fn map_request( Some(draft.id.as_str().clone()), None, ), + Request::UpdateDraft { draft } => ( + "draft.update", + Some("draft"), + Some(draft.id.as_str().clone()), + None, + ), Request::DeleteDraft { draft_id } => ( "draft.discard", Some("draft"), @@ -611,7 +617,8 @@ pub fn map_request( | Request::GetDecision { .. } | Request::ListOwedReplies { .. } | Request::ListDrafts - | Request::ListOrphanedDrafts => { + | Request::ListOrphanedDrafts + | Request::GetDraft { .. } => { skip_activity!( "read_list_status", "read/list/status requests are not activity entries" diff --git a/crates/daemon/src/cli/mod.rs b/crates/daemon/src/cli/mod.rs index d04b990f..609ec19e 100644 --- a/crates/daemon/src/cli/mod.rs +++ b/crates/daemon/src/cli/mod.rs @@ -1689,6 +1689,13 @@ pub enum DraftsAction { /// Draft ID to delete. draft_id: String, }, + /// Open an existing draft in `$EDITOR` and re-save it in place under + /// the same draft id. No new draft is created and nothing is + /// discarded. + Edit { + /// Draft ID to edit. + draft_id: String, + }, } #[derive(Debug, Clone, Subcommand)] diff --git a/crates/daemon/src/commands/mutations/compose.rs b/crates/daemon/src/commands/mutations/compose.rs index 4319fb2c..8796d4a8 100644 --- a/crates/daemon/src/commands/mutations/compose.rs +++ b/crates/daemon/src/commands/mutations/compose.rs @@ -638,6 +638,86 @@ pub async fn drafts_discard(draft_id: String, account: Option) -> anyhow } } +/// CLI surface for `mxr drafts edit `. Opens an existing stored draft +/// in `$EDITOR` and re-saves it in place under the same `DraftId` — no +/// new draft is created and nothing is discarded. +pub async fn drafts_edit(draft_id: String, account: Option) -> anyhow::Result<()> { + let parsed = DraftId::from_uuid(uuid::Uuid::parse_str(&draft_id)?); + let mut client = IpcClient::connect().await?; + let account_id = resolve_optional_account(&mut client, account.as_deref()).await?; + + let draft = crate::commands::expect_response( + client + .request(Request::GetDraft { + draft_id: parsed.clone(), + }) + .await?, + |response| match response { + Response::Ok { + data: ResponseData::Draft { draft }, + } => Some(draft), + _ => None, + }, + )?; + if account_id.is_some_and(|account_id| draft.account_id != account_id) { + anyhow::bail!("Draft {parsed} belongs to a different account"); + } + + let from = resolve_account_email(&mut client, &draft.account_id).await?; + + let content = mxr_compose::draft_codec::draft_to_compose_file(&draft, &from)?; + // A draft is unsent mail content; write it to the per-user private 0700 + // scratch dir with 0600 perms rather than the world-readable temp root. + let path = mxr_compose::private_tmp::private_scratch_dir()? + .join(format!("mxr-draft-edit-{}.md", draft.id)); + // Clear any leftover from a prior aborted edit so the O_EXCL write succeeds. + let _ = std::fs::remove_file(&path); + mxr_compose::private_tmp::write_private(&path, content.as_bytes())?; + + let editor = mxr_compose::editor::resolve_editor(None); + mxr_compose::editor::spawn_editor(&editor, &path, None).await?; + + let edited = std::fs::read_to_string(&path)?; + let (frontmatter, body) = mxr_compose::frontmatter::parse_compose_file(&edited)?; + validate_compose_draft(&frontmatter, &body, false)?; + + let updated = + mxr_compose::draft_codec::apply_edited_compose_file(&draft, &edited, chrono::Utc::now())?; + + expect_ack( + client + .request(Request::UpdateDraft { + draft: updated.clone(), + }) + .await?, + )?; + + let _ = std::fs::remove_file(&path); + println!("Draft updated: {}", updated.id); + Ok(()) +} + +/// Resolve the sending address for an account by id. Used when editing a +/// stored draft, where we have `draft.account_id` but not the CLI's +/// `--from`/`--account` selector string that `resolve_compose_account` expects. +async fn resolve_account_email( + client: &mut IpcClient, + account_id: &AccountId, +) -> anyhow::Result { + let resp = client.request(Request::ListAccounts).await?; + let accounts = crate::commands::expect_response(resp, |response| match response { + Response::Ok { + data: ResponseData::Accounts { accounts }, + } => Some(accounts), + _ => None, + })?; + accounts + .into_iter() + .find(|account| &account.account_id == account_id) + .map(|account| account.email) + .ok_or_else(|| anyhow::anyhow!("Account {account_id} not found")) +} + pub async fn send_draft( draft_id: String, account: Option, diff --git a/crates/daemon/src/commands/mutations/mod.rs b/crates/daemon/src/commands/mutations/mod.rs index ffae5949..d5bfc713 100644 --- a/crates/daemon/src/commands/mutations/mod.rs +++ b/crates/daemon/src/commands/mutations/mod.rs @@ -4,7 +4,7 @@ mod helpers; pub use attachments::{attachments_download, attachments_list, attachments_open}; pub use compose::{ - cancel_scheduled_send, check_send, compose, compose_check, drafts, drafts_discard, + cancel_scheduled_send, check_send, compose, compose_check, drafts, drafts_discard, drafts_edit, drafts_recover, drafts_resume, forward, reply, reply_all, schedule_send, send_draft, ComposeOptions, ForwardCommand, ReplyCommand, }; diff --git a/crates/daemon/src/handler/mod.rs b/crates/daemon/src/handler/mod.rs index 17fb1c0f..064b0be8 100644 --- a/crates/daemon/src/handler/mod.rs +++ b/crates/daemon/src/handler/mod.rs @@ -1257,6 +1257,8 @@ async fn dispatch(state: &Arc, source: ClientKind, req: &Request) -> R commitments_extract::extract_request(state, draft).await } Request::DeleteDraft { draft_id } => mutations::delete_draft(state, draft_id).await, + Request::GetDraft { draft_id } => mutations::get_draft(state, draft_id).await, + Request::UpdateDraft { draft } => mutations::update_draft(state, draft).await, Request::SaveDraftToServer { draft } => mutations::save_draft_to_server(state, draft).await, Request::Unsubscribe { message_id } => mutations::unsubscribe(state, message_id).await, Request::UnsubscribePurge { @@ -1594,6 +1596,7 @@ async fn request_account_scope( } => envelope_account_scope(state, std::slice::from_ref(message_id)).await, Request::SendStoredDraft { draft_id, .. } | Request::DeleteDraft { draft_id } + | Request::GetDraft { draft_id } | Request::ScheduleSend { draft_id, .. } | Request::CancelScheduledSend { draft_id } => draft_account_scope(state, draft_id).await, Request::DraftRefine { draft_id, .. } => draft_account_scope(state, draft_id).await, @@ -1815,6 +1818,7 @@ fn classify_request(req: &Request) -> RequestClass { | Request::ListCadenceDrift { .. } | Request::ListDrafts | Request::ListOrphanedDrafts + | Request::GetDraft { .. } | Request::ExportThread { .. } | Request::ExportSearch { .. } | Request::GetStatus @@ -1828,8 +1832,10 @@ fn classify_request(req: &Request) -> RequestClass { | Request::ListSavedActivityFilters | Request::GetSavedActivityFilter { .. } => Read, - // --- DraftOnly: local draft create/delete --- - Request::SaveDraft { .. } | Request::DeleteDraft { .. } => DraftOnly, + // --- DraftOnly: local draft create/update/delete --- + Request::SaveDraft { .. } | Request::UpdateDraft { .. } | Request::DeleteDraft { .. } => { + DraftOnly + } // --- Send: transmits mail to a provider --- Request::SendDraft { .. } @@ -2112,6 +2118,8 @@ fn request_kind(req: &Request) -> &'static str { Request::ListCadenceWatch { .. } => "list_cadence_watch", Request::ListCadenceDrift { .. } => "list_cadence_drift", Request::DeleteDraft { .. } => "delete_draft", + Request::GetDraft { .. } => "get_draft", + Request::UpdateDraft { .. } => "update_draft", Request::SaveDraftToServer { .. } => "save_draft_to_server", Request::ListDrafts => "list_drafts", Request::ListOrphanedDrafts => "list_orphaned_drafts", @@ -2204,6 +2212,7 @@ fn request_account_id(req: &Request) -> Option<&mxr_core::AccountId> { Request::GetSyncStatus { account_id } => Some(account_id), Request::SendDraft { draft, .. } | Request::SaveDraft { draft } + | Request::UpdateDraft { draft } | Request::SaveDraftToServer { draft } | Request::CheckDraftSafety { draft, .. } | Request::ExtractDraftCommitments { draft } diff --git a/crates/daemon/src/handler/mutations.rs b/crates/daemon/src/handler/mutations.rs index 04833eed..295fd88e 100644 --- a/crates/daemon/src/handler/mutations.rs +++ b/crates/daemon/src/handler/mutations.rs @@ -1866,6 +1866,42 @@ pub(super) async fn save_draft(state: &AppState, draft: &Draft) -> HandlerResult Ok(ResponseData::Ack) } +/// Fetch a single stored draft by id so a client can load it into `$EDITOR`. +pub(super) async fn get_draft(state: &AppState, draft_id: &mxr_core::DraftId) -> HandlerResult { + let draft = state + .store + .get_draft(draft_id) + .await? + .ok_or_else(|| format!("Draft not found: {draft_id}"))?; + Ok(ResponseData::Draft { draft }) +} + +/// Update a stored draft in place (same `DraftId`). The store only touches +/// rows still in `'draft'` status; when nothing is updated we inspect the +/// current status to return a precise reason. +pub(super) async fn update_draft(state: &AppState, draft: &Draft) -> HandlerResult { + if state.store.update_draft(draft).await? { + return Ok(ResponseData::Ack); + } + match state.store.get_draft_status(&draft.id).await? { + None => Err(crate::handler::HandlerError::Message(format!( + "Draft not found: {}", + draft.id + ))), + // Row is editable yet UPDATE matched nothing — a concurrent status + // transition slipped between the update and this read; ask for a retry. + Some(DraftStatus::Draft) => Err(crate::handler::HandlerError::Message( + "draft state changed during edit; retry".to_string(), + )), + Some(DraftStatus::Sending) => Err(crate::handler::HandlerError::Message( + "draft is sending and cannot be edited".to_string(), + )), + Some(DraftStatus::Sent) => Err(crate::handler::HandlerError::Message( + "draft has been sent and cannot be edited".to_string(), + )), + } +} + pub(super) async fn delete_draft(state: &AppState, draft_id: &mxr_core::DraftId) -> HandlerResult { state.store.delete_draft(draft_id).await?; Ok(ResponseData::Ack) diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index 3cc065e8..a42017a8 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -899,6 +899,9 @@ pub async fn run_cli(args: Vec) -> anyhow::Result<()> { Some(crate::cli::DraftsAction::Discard { draft_id }) => { commands::mutations::drafts_discard(draft_id, account).await?; } + Some(crate::cli::DraftsAction::Edit { draft_id }) => { + commands::mutations::drafts_edit(draft_id, account).await?; + } } } Some(Command::Send { diff --git a/crates/daemon/tests/cli_help.rs b/crates/daemon/tests/cli_help.rs index 4b7477ad..e7297b58 100644 --- a/crates/daemon/tests/cli_help.rs +++ b/crates/daemon/tests/cli_help.rs @@ -188,6 +188,7 @@ fn cli_help_snapshots_cover_all_commands() { ("cli_help_drafts_recover", &["drafts", "recover", "--help"]), ("cli_help_drafts_resume", &["drafts", "resume", "--help"]), ("cli_help_drafts_discard", &["drafts", "discard", "--help"]), + ("cli_help_drafts_edit", &["drafts", "edit", "--help"]), ("cli_help_send", &["send", "--help"]), ("cli_help_unsubscribe", &["unsubscribe", "--help"]), ("cli_help_attachments", &["attachments", "--help"]), @@ -338,7 +339,7 @@ fn cli_help_snapshots_cover_all_commands() { ), ]; - assert_eq!(cases.len(), 186); + assert_eq!(cases.len(), 187); for (name, args) in cases { assert_help_snapshot(name, args); diff --git a/crates/daemon/tests/snapshots/cli_help__cli_help_drafts.snap b/crates/daemon/tests/snapshots/cli_help__cli_help_drafts.snap index 0b23b0fb..a6fe9cf4 100644 --- a/crates/daemon/tests/snapshots/cli_help__cli_help_drafts.snap +++ b/crates/daemon/tests/snapshots/cli_help__cli_help_drafts.snap @@ -11,6 +11,7 @@ Commands: recover Show drafts that look orphaned mid-send (status `'sending'`, stale heartbeat). The startup loop already auto-resets these after 1h; this surfaces them earlier so you can act now resume Force-reset an orphaned draft to `'draft'` status so it can be re-sent via the normal pipeline. No-op if the draft is already in `'draft'` discard Permanently delete a draft. Use this when a recovered draft is no longer wanted instead of leaving it in the drafts list + edit Open an existing draft in `$EDITOR` and re-save it in place under the same draft id. No new draft is created and nothing is discarded help Print this message or the help of the given subcommand(s) Options: diff --git a/crates/daemon/tests/snapshots/cli_help__cli_help_drafts_edit.snap b/crates/daemon/tests/snapshots/cli_help__cli_help_drafts_edit.snap new file mode 100644 index 00000000..aa58f786 --- /dev/null +++ b/crates/daemon/tests/snapshots/cli_help__cli_help_drafts_edit.snap @@ -0,0 +1,13 @@ +--- +source: crates/daemon/tests/cli_help.rs +expression: stdout +--- +Open an existing draft in `$EDITOR` and re-save it in place under the same draft id. No new draft is created and nothing is discarded + +Usage: mxr drafts edit + +Arguments: + Draft ID to edit + +Options: + -h, --help Print help diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 30bf9c86..ad2b8e8d 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -614,6 +614,18 @@ mod tests { }, IpcCategory::CoreMail, ), + ( + Request::GetDraft { + draft_id: DraftId::new(), + }, + IpcCategory::CoreMail, + ), + ( + Request::UpdateDraft { + draft: sample_draft(), + }, + IpcCategory::CoreMail, + ), ( Request::SaveDraftToServer { draft: sample_draft(), diff --git a/crates/protocol/src/types.rs b/crates/protocol/src/types.rs index 203a88f5..1f9bb5f5 100644 --- a/crates/protocol/src/types.rs +++ b/crates/protocol/src/types.rs @@ -1316,6 +1316,17 @@ pub enum Request { DeleteDraft { draft_id: DraftId, }, + /// Fetch a single locally-stored draft by id (for editing in place). + /// Returns `ResponseData::Draft`; errors when no such draft exists. + GetDraft { + draft_id: DraftId, + }, + /// Update a locally-stored draft in place, keyed by `draft.id`. Preserves + /// the draft's `created_at`; only drafts still in `'draft'` status are + /// editable. Errors when the draft is missing or already sending/sent. + UpdateDraft { + draft: Draft, + }, /// Save draft to the mail server (e.g. Gmail Drafts folder). SaveDraftToServer { draft: Draft, @@ -1533,6 +1544,8 @@ impl Request { | Self::FindExpert { .. } | Self::ExplainEntity { .. } | Self::DeleteDraft { .. } + | Self::GetDraft { .. } + | Self::UpdateDraft { .. } | Self::SaveDraftToServer { .. } | Self::ListDrafts | Self::ListOrphanedDrafts @@ -2022,6 +2035,10 @@ pub enum ResponseData { Drafts { drafts: Vec, }, + /// A single locally-stored draft, returned by `Request::GetDraft`. + Draft { + draft: Draft, + }, SnoozedMessages { snoozed: Vec, }, @@ -2418,6 +2435,7 @@ impl ResponseData { | Self::ReplyContext { .. } | Self::ForwardContext { .. } | Self::Drafts { .. } + | Self::Draft { .. } | Self::SnoozedMessages { .. } | Self::ReplyQueue { .. } | Self::Snippets { .. } diff --git a/crates/store/src/draft.rs b/crates/store/src/draft.rs index c5a92e1c..143083f1 100644 --- a/crates/store/src/draft.rs +++ b/crates/store/src/draft.rs @@ -133,6 +133,56 @@ impl super::Store { .transpose() } + /// Update an existing draft in place, preserving `created_at`. Only rows + /// still in `'draft'` status are editable — a draft mid-send (`'sending'`) + /// or already `'sent'` is left untouched. Returns `true` when a row was + /// updated, `false` when no editable draft with this id exists (caller + /// distinguishes "not found" from "not editable" via `get_draft`). + pub async fn update_draft(&self, draft: &Draft) -> Result { + let id = draft.id.as_str(); + let account_id = draft.account_id.as_str(); + let to_addrs = encode_json(&draft.to)?; + let cc_addrs = encode_json(&draft.cc)?; + let bcc_addrs = encode_json(&draft.bcc)?; + let attachments = encode_json(&draft.attachments)?; + let in_reply_to = draft.reply_headers.as_ref().map(encode_json).transpose()?; + let intent = draft.intent.as_db_str(); + let inline_calendar_reply_json = draft + .inline_calendar_reply + .as_ref() + .map(encode_json) + .transpose()?; + let updated_at = draft.updated_at.timestamp(); + + // `created_at` is intentionally absent from SET so the original + // creation time survives the edit. Plain UPDATE (not INSERT OR + // REPLACE) so the account_id FK's ON DELETE CASCADE is never armed. + let result = sqlx::query( + "UPDATE drafts + SET account_id = ?, in_reply_to = ?, intent = ?, + to_addrs = ?, cc_addrs = ?, bcc_addrs = ?, subject = ?, + body_markdown = ?, attachments = ?, inline_calendar_reply_json = ?, + updated_at = ? + WHERE id = ? AND status = 'draft'", + ) + .bind(account_id) + .bind(in_reply_to) + .bind(intent) + .bind(to_addrs) + .bind(cc_addrs) + .bind(bcc_addrs) + .bind(&draft.subject) + .bind(&draft.body_markdown) + .bind(attachments) + .bind(inline_calendar_reply_json) + .bind(updated_at) + .bind(id) + .execute(self.writer()) + .await?; + + Ok(result.rows_affected() > 0) + } + pub async fn list_drafts(&self, account_id: &AccountId) -> Result, sqlx::Error> { let aid = account_id.as_str(); let started_at = std::time::Instant::now(); diff --git a/crates/store/src/tests.rs b/crates/store/src/tests.rs index 9849569b..576e9451 100644 --- a/crates/store/src/tests.rs +++ b/crates/store/src/tests.rs @@ -959,6 +959,64 @@ async fn draft_crud() { assert_eq!(drafts.len(), 0); } +#[tokio::test] +async fn update_draft_edits_in_place_and_preserves_created_at() { + let store = Store::in_memory().await.unwrap(); + let account = test_account(); + store.insert_account(&account).await.unwrap(); + + let created = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(); + let draft = Draft { + id: DraftId::new(), + account_id: account.id.clone(), + reply_headers: None, + intent: DraftIntent::New, + to: vec![Address { + name: None, + email: "bob@example.com".to_string(), + }], + cc: vec![], + bcc: vec![], + subject: "Original".to_string(), + body_markdown: "first".to_string(), + attachments: vec![], + inline_calendar_reply: None, + created_at: created, + updated_at: created, + }; + store.insert_draft(&draft).await.unwrap(); + + // Edit in place: same id, new content, later updated_at. + let later = chrono::DateTime::from_timestamp(1_700_000_500, 0).unwrap(); + let edited = Draft { + subject: "Edited".to_string(), + body_markdown: "second".to_string(), + updated_at: later, + ..draft.clone() + }; + let updated = store.update_draft(&edited).await.unwrap(); + assert!(updated, "an editable draft should report a row updated"); + + // Same id still there (no new row), content changed, created_at preserved. + let drafts = store.list_drafts(&account.id).await.unwrap(); + assert_eq!(drafts.len(), 1); + let stored = store.get_draft(&draft.id).await.unwrap().unwrap(); + assert_eq!(stored.subject, "Edited"); + assert_eq!(stored.body_markdown, "second"); + assert_eq!( + stored.created_at, created, + "created_at must survive the edit" + ); + assert_eq!(stored.updated_at, later); + + // Updating a non-existent draft reports no row touched. + let ghost = Draft { + id: DraftId::new(), + ..edited + }; + assert!(!store.update_draft(&ghost).await.unwrap()); +} + #[tokio::test] async fn snooze_lifecycle() { let store = Store::in_memory().await.unwrap(); diff --git a/crates/tui/src/action.rs b/crates/tui/src/action.rs index 09bfafb7..81b626a9 100644 --- a/crates/tui/src/action.rs +++ b/crates/tui/src/action.rs @@ -176,6 +176,19 @@ pub enum Action { SnippetsModalNext, /// Move cursor to the previous snippet in the modal list. SnippetsModalPrev, + /// Open the stored-drafts browser modal (locally-saved drafts, + /// across all accounts, edit-in-place via `$EDITOR`). + OpenStoredDrafts, + /// Close the stored-drafts modal (Esc). + CloseStoredDraftsModal, + /// Move cursor to the next draft in the modal list. + StoredDraftsModalNext, + /// Move cursor to the previous draft in the modal list. + StoredDraftsModalPrev, + /// Edit the selected stored draft in place via `$EDITOR`. The result + /// round-trips through `Request::UpdateDraft`, preserving the + /// draft's id instead of minting a new one. + StoredDraftsModalEdit, ClearFilter, RefreshRules, ToggleRuleEnabled, diff --git a/crates/tui/src/app/actions.rs b/crates/tui/src/app/actions.rs index 9f8368da..9a2cf3f5 100644 --- a/crates/tui/src/app/actions.rs +++ b/crates/tui/src/app/actions.rs @@ -203,6 +203,11 @@ impl App { | Action::CloseSnippetsModal | Action::SnippetsModalNext | Action::SnippetsModalPrev + | Action::OpenStoredDrafts + | Action::CloseStoredDraftsModal + | Action::StoredDraftsModalNext + | Action::StoredDraftsModalPrev + | Action::StoredDraftsModalEdit | Action::ClearFilter | Action::OpenMessageView | Action::CloseMessageView diff --git a/crates/tui/src/app/draw.rs b/crates/tui/src/app/draw.rs index b09dcd36..9e2f9c98 100644 --- a/crates/tui/src/app/draw.rs +++ b/crates/tui/src/app/draw.rs @@ -528,6 +528,9 @@ impl App { // Reply-later queue modal. ui::reply_queue_modal::draw(frame, area, &self.modals.reply_queue, theme); + // Stored-drafts modal — local drafts, edit-in-place. + ui::drafts_modal::draw(frame, area, &self.modals.drafts, theme); + // Activity log modal (Phase 5). ui::activity_modal::draw(frame, area, &self.modals.activity, theme); diff --git a/crates/tui/src/app/input.rs b/crates/tui/src/app/input.rs index ace0e4ad..4f2e782b 100644 --- a/crates/tui/src/app/input.rs +++ b/crates/tui/src/app/input.rs @@ -415,6 +415,16 @@ impl App { }; } + if self.modals.drafts.visible { + return match (key.code, key.modifiers) { + (KeyCode::Esc | KeyCode::Char('q'), _) => Some(Action::CloseStoredDraftsModal), + (KeyCode::Down | KeyCode::Char('j'), _) => Some(Action::StoredDraftsModalNext), + (KeyCode::Up | KeyCode::Char('k'), _) => Some(Action::StoredDraftsModalPrev), + (KeyCode::Enter | KeyCode::Char('e'), _) => Some(Action::StoredDraftsModalEdit), + _ => None, + }; + } + if self.modals.screener.visible { return match (key.code, key.modifiers) { (KeyCode::Esc, _) => Some(Action::CloseScreenerModal), diff --git a/crates/tui/src/app/mailbox_actions.rs b/crates/tui/src/app/mailbox_actions.rs index 4734b578..a61ddb01 100644 --- a/crates/tui/src/app/mailbox_actions.rs +++ b/crates/tui/src/app/mailbox_actions.rs @@ -674,6 +674,29 @@ impl App { Action::SnippetsModalPrev => { self.modals.snippets.select_prev(); } + Action::OpenStoredDrafts => { + self.modals.drafts.open_loading(); + self.pending_drafts_refresh = true; + self.status_message = Some("Loading drafts...".into()); + } + Action::CloseStoredDraftsModal => { + self.modals.drafts.close(); + } + Action::StoredDraftsModalNext => { + self.modals.drafts.select_next(); + } + Action::StoredDraftsModalPrev => { + self.modals.drafts.select_prev(); + } + Action::StoredDraftsModalEdit => { + let Some(draft) = self.modals.drafts.selected().cloned() else { + self.status_message = Some("No draft selected".into()); + return; + }; + self.modals.drafts.close(); + self.pending_draft_edit_request = Some(draft); + self.status_message = Some("Opening draft in editor...".into()); + } Action::SelectSavedSearch(query, mode) => { self.mailbox.mailbox_view = MailboxView::Messages; if self.screen == Screen::Search { diff --git a/crates/tui/src/app/mod.rs b/crates/tui/src/app/mod.rs index 423c5c3c..a9178d2b 100644 --- a/crates/tui/src/app/mod.rs +++ b/crates/tui/src/app/mod.rs @@ -244,6 +244,15 @@ pub struct App { /// Set when the reply-later queue modal opens; the runtime drains /// this flag and dispatches a `Request::ListReplyQueue`. pub pending_reply_queue_refresh: bool, + /// Set when the stored-drafts modal opens; the runtime drains this + /// flag and dispatches a `Request::ListDrafts`. + pub pending_drafts_refresh: bool, + /// Set when the user presses edit on a selected stored draft. The + /// runtime resolves the sending account's email, renders the draft + /// to an editor-ready compose file in the private scratch dir, and + /// reports back via `AsyncResult::DraftEditReady` so `$EDITOR` can + /// be opened on it. + pub pending_draft_edit_request: Option, /// Set when the Deliveries screen opens or its filter changes; the /// runtime drains this and dispatches a `Request::ListDeliveries`. pub pending_deliveries_refresh: bool, @@ -387,6 +396,8 @@ impl App { pending_connection_retry: false, pending_snippets_refresh: false, pending_reply_queue_refresh: false, + pending_drafts_refresh: false, + pending_draft_edit_request: None, pending_deliveries_refresh: false, pending_delivery_resolve: None, pending_delivery_dismiss: None, diff --git a/crates/tui/src/app/state/mod.rs b/crates/tui/src/app/state/mod.rs index 004d5dad..e5ecbc07 100644 --- a/crates/tui/src/app/state/mod.rs +++ b/crates/tui/src/app/state/mod.rs @@ -33,8 +33,8 @@ pub use mailbox::{ }; pub use modals::{ ActivityModalState, AnalyticsFilterField, AnalyticsFilterModalState, BriefingModalState, - BriefingModalSubject, DraftOptionsField, DraftOptionsModalState, ErrorModalState, - ExpertModalState, FeatureOnboardingState, ModalsState, PendingBulkConfirm, + BriefingModalSubject, DraftOptionsField, DraftOptionsModalState, DraftsModalState, + ErrorModalState, ExpertModalState, FeatureOnboardingState, ModalsState, PendingBulkConfirm, PendingPlatformDispatch, PendingUnsubscribeAction, PendingUnsubscribeConfirm, PlatformModalState, ReplyQueueModalState, SaveAttachmentModalState, SavedSearchFormField, SavedSearchFormState, ScreenerModalState, SenderProfileModalState, SenderProfileTab, diff --git a/crates/tui/src/app/state/modals.rs b/crates/tui/src/app/state/modals.rs index 7932cd93..192ac411 100644 --- a/crates/tui/src/app/state/modals.rs +++ b/crates/tui/src/app/state/modals.rs @@ -1,7 +1,7 @@ use super::super::MutationEffect; use crate::ui::label_picker::{LabelPicker, LabelPickerMode}; use mxr_core::id::{AccountId, MessageId, ThreadId}; -use mxr_core::Envelope; +use mxr_core::{Draft, Envelope}; use mxr_protocol::{ DraftLengthHintData, Request, ScreenerQueueEntryData, SenderProfileData, SnippetData, VoiceRegisterData, @@ -126,6 +126,11 @@ pub struct ModalsState { /// can walk them. Read-only: actually replying still uses the /// regular reply flow once the user opens the focused message. pub reply_queue: ReplyQueueModalState, + /// Stored-drafts browser modal listing locally-saved drafts across + /// all accounts. `e`/Enter opens the selected draft for + /// edit-in-place; the edit round-trips through `Request::UpdateDraft` + /// so the same `DraftId` is preserved instead of minting a new draft. + pub drafts: DraftsModalState, /// Activity-log modal (Phase 5). Read-only browser of recent /// `user_activity` rows fetched via IPC. pub activity: ActivityModalState, @@ -682,6 +687,68 @@ impl ReplyQueueModalState { } } +/// Read-only state for the stored-drafts browser modal: locally-saved +/// drafts across all accounts, most recently updated first. `visible` +/// tracks whether the modal is open; `loading` gates the spinner +/// between dispatch and the `Request::ListDrafts` response. +#[derive(Debug, Clone, Default)] +pub struct DraftsModalState { + pub visible: bool, + pub loading: bool, + pub drafts: Vec, + pub selected_index: usize, + pub error: Option, +} + +impl DraftsModalState { + pub fn open_loading(&mut self) { + self.visible = true; + self.loading = true; + self.drafts.clear(); + self.selected_index = 0; + self.error = None; + } + + pub fn close(&mut self) { + self.visible = false; + self.loading = false; + self.error = None; + } + + pub fn set_drafts(&mut self, drafts: Vec) { + self.loading = false; + self.error = None; + self.drafts = drafts; + self.selected_index = 0; + } + + pub fn set_error(&mut self, message: String) { + self.loading = false; + self.error = Some(message); + } + + pub fn select_next(&mut self) { + if self.drafts.is_empty() { + return; + } + self.selected_index = (self.selected_index + 1) % self.drafts.len(); + } + + pub fn select_prev(&mut self) { + if self.drafts.is_empty() { + return; + } + self.selected_index = self + .selected_index + .checked_sub(1) + .unwrap_or(self.drafts.len() - 1); + } + + pub fn selected(&self) -> Option<&Draft> { + self.drafts.get(self.selected_index) + } +} + /// State for the thread-summary modal. Holds the LLM result while the /// modal is visible; `error` carries the daemon-side message verbatim /// (e.g. `LlmDisabled`, `ThreadTooShort`). diff --git a/crates/tui/src/async_result.rs b/crates/tui/src/async_result.rs index 21da8d40..ca9b3b9c 100644 --- a/crates/tui/src/async_result.rs +++ b/crates/tui/src/async_result.rs @@ -2,7 +2,7 @@ use crate::app::{self, AttachmentOperation}; use crate::terminal_images::HtmlImageKey; use image::DynamicImage; use mxr_core::types::SubscriptionSummary; -use mxr_core::{Envelope, Label, MessageBody, MessageId, MxrError, Thread, ThreadId}; +use mxr_core::{Draft, Envelope, Label, MessageBody, MessageId, MxrError, Thread, ThreadId}; use mxr_protocol::{ AccountOperationResult, AccountSummaryData, AccountSyncStatus, AttachmentFile, AuthSessionData, BodyFailure, DaemonEvent, DeliveryData, ReplyContext, Response, RuleFormData, @@ -197,6 +197,14 @@ pub(crate) enum AsyncResult { thread_id: mxr_core::ThreadId, result: Result<(String, String), MxrError>, }, + /// Snapshot of the user's locally-stored drafts (all accounts, most + /// recently updated first), surfaced by the stored-drafts modal. + StoredDraftsLoaded(Result, MxrError>), + /// Editor-ready payload for editing a stored draft in place. Mirrors + /// `ComposeReady`, but carries the *existing* `Draft` so the + /// post-edit update preserves its id/account_id/created_at instead + /// of minting a new draft. + DraftEditReady(Result), } #[derive(Debug, Clone)] @@ -245,3 +253,11 @@ pub(crate) struct UnsubscribeResultData { pub(crate) archived_ids: Vec, pub(crate) message: String, } + +/// Payload for [`AsyncResult::DraftEditReady`]: the stored draft being +/// edited plus the path of the editor-ready compose file rendered from +/// it. `existing` is boxed to keep the `AsyncResult` enum lean. +pub(crate) struct DraftEditReadyData { + pub(crate) existing: Box, + pub(crate) path: std::path::PathBuf, +} diff --git a/crates/tui/src/compose_flow.rs b/crates/tui/src/compose_flow.rs index 910426e4..37d65ebb 100644 --- a/crates/tui/src/compose_flow.rs +++ b/crates/tui/src/compose_flow.rs @@ -1,7 +1,8 @@ use crate::app::{App, ComposeAction, PendingSend, PendingSendMode}; -use crate::async_result::ComposeReadyData; +use crate::async_result::{ComposeReadyData, DraftEditReadyData}; use crate::ipc::{ipc_call, ipc_call_dedicated, IpcRequest}; use mxr_core::AccountId; +use mxr_core::Draft; use mxr_core::MessageId; use mxr_core::MxrError; use mxr_protocol::{ @@ -229,6 +230,124 @@ pub(crate) async fn handle_compose_action( }) } +/// Render a locally-stored draft to an editor-ready compose file and +/// write it to the private scratch dir, ready for `$EDITOR`. Sibling of +/// `handle_compose_action`: unlike the regular compose flow, the result +/// carries the *existing* `Draft` so the post-edit save round-trips +/// through `Request::UpdateDraft` and keeps the same `DraftId` instead +/// of minting a new one. +pub(crate) async fn prepare_draft_edit( + bg: &mpsc::UnboundedSender, + draft: Draft, +) -> Result { + let account = resolve_compose_account(bg, Some(&draft.account_id)).await?; + let content = mxr_compose::draft_codec::draft_to_compose_file(&draft, &account.email) + .map_err(|e| MxrError::Ipc(e.to_string()))?; + + let dir = mxr_compose::private_tmp::private_scratch_dir() + .map_err(|e| MxrError::Ipc(e.to_string()))?; + let path = dir.join(format!("mxr-draft-edit-{}.md", draft.id)); + // Best-effort: clear a stale file left behind by a previous + // cancelled/failed edit of this same draft — `write_private` uses + // O_EXCL and would otherwise refuse to write. + let _ = tokio::fs::remove_file(&path).await; + mxr_compose::private_tmp::write_private_async(&path, content.as_bytes()) + .await + .map_err(|e| MxrError::Ipc(e.to_string()))?; + + Ok(DraftEditReadyData { + existing: Box::new(draft), + path, + }) +} + +/// Handle the `$EDITOR` exit status for an in-place draft edit. Mirrors +/// `handle_compose_editor_status`, but the edited content is applied +/// onto the existing draft (preserving id/account_id/created_at) and +/// saved via `Request::UpdateDraft` rather than entering the +/// send/save-as-new-draft confirmation flow. +pub(crate) async fn handle_draft_edit_status( + app: &mut App, + data: &DraftEditReadyData, + status: std::io::Result, + bg: &mpsc::UnboundedSender, +) { + match status { + Ok(s) if s.success() => { + let content = match mxr_compose::read_draft_file_async(&data.path).await { + Ok(content) => content, + Err(error) => { + app.report_error( + "Edit Draft Failed", + format!("Failed to read draft: {error}"), + ); + return; + } + }; + let updated = match mxr_compose::draft_codec::apply_edited_compose_file( + &data.existing, + &content, + chrono::Utc::now(), + ) { + Ok(updated) => updated, + Err(error) => { + // Keep the temp file so the user's edits aren't lost — + // there's no in-app retry for this path, so surfacing + // the path lets them recover the content by hand. + app.report_error( + "Edit Draft Failed", + format!( + "Could not parse the edited draft: {error}\n\nYour edits are still at {}", + data.path.display() + ), + ); + return; + } + }; + + match ipc_call(bg, Request::UpdateDraft { draft: updated }).await { + Ok(Response::Ok { + data: ResponseData::Ack, + }) => { + app.status_message = Some("Draft updated".into()); + app.schedule_draft_cleanup(data.path.clone()); + } + Ok(Response::Error { message, .. }) => { + app.report_error( + "Update Draft Failed", + format!( + "{message}\n\nYour edits are still at {}", + data.path.display() + ), + ); + } + Ok(_) => { + app.report_error( + "Update Draft Failed", + "Unexpected daemon response to UpdateDraft".to_string(), + ); + } + Err(error) => { + app.report_error( + "Update Draft Failed", + format!("{error}\n\nYour edits are still at {}", data.path.display()), + ); + } + } + } + Ok(_) => { + app.status_message = Some("Edit cancelled".into()); + app.schedule_draft_cleanup(data.path.clone()); + } + Err(error) => { + app.report_error( + "Edit Draft Failed", + format!("Failed to launch editor: {error}"), + ); + } + } +} + fn invite_action_to_partstat( action: mxr_protocol::CalendarInviteActionData, ) -> mxr_core::types::CalendarPartstat { diff --git a/crates/tui/src/keybindings.rs b/crates/tui/src/keybindings.rs index fc182ba2..5852fdcf 100644 --- a/crates/tui/src/keybindings.rs +++ b/crates/tui/src/keybindings.rs @@ -171,6 +171,7 @@ pub fn action_from_name(name: &str) -> Option { "rebuild_user_voice" => Some(Action::RebuildUserVoice), "open_commitments" | "commitments" => Some(Action::OpenCommitments), "open_activity" | "activity" | "open_activity_screen" => Some(Action::OpenActivityScreen), + "open_stored_drafts" => Some(Action::OpenStoredDrafts), "close_activity_modal" => Some(Action::CloseActivityModal), "activity_next" => Some(Action::ActivityModalNext), "activity_prev" => Some(Action::ActivityModalPrev), @@ -491,6 +492,7 @@ const ML_DEFAULTS: &[(&str, &str)] = &[ ("gD", "draft_new_for_sender"), ("gC", "open_commitments"), ("gV", "open_voice_profile"), + ("gE", "open_stored_drafts"), ]; // Message view defaults diff --git a/crates/tui/src/runner.rs b/crates/tui/src/runner.rs index f7470d10..07bf425c 100644 --- a/crates/tui/src/runner.rs +++ b/crates/tui/src/runner.rs @@ -18,6 +18,7 @@ use crate::accounts_helpers::load_accounts_page_accounts; use crate::async_result::{AsyncResult, UnsubscribeResultData}; use crate::compose_flow::{ fetch_reply_context_dedicated, handle_compose_action, handle_compose_editor_status, + handle_draft_edit_status, prepare_draft_edit, }; use crate::daemon_events::{ apply_all_envelopes_refresh, apply_labels_refresh, apply_thread_summary_loaded, @@ -591,6 +592,30 @@ pub async fn run() -> anyhow::Result<()> { }); } + if app.pending_drafts_refresh { + app.pending_drafts_refresh = false; + let bg = bg.clone(); + let _ = submit_task(&queued, async move { + let resp = ipc_call(&bg, Request::ListDrafts).await; + let result = match resp { + Ok(Response::Ok { + data: ResponseData::Drafts { drafts }, + }) => Ok(drafts), + Ok(Response::Error { message, .. }) => Err(MxrError::Ipc(message)), + Err(e) => Err(e), + _ => Err(MxrError::Ipc("unexpected response to ListDrafts".into())), + }; + AsyncResult::StoredDraftsLoaded(result) + }); + } + + if let Some(draft) = app.pending_draft_edit_request.take() { + let bg = bg.clone(); + let _ = submit_task(&queued, async move { + AsyncResult::DraftEditReady(prepare_draft_edit(&bg, draft).await) + }); + } + if app.pending_deliveries_refresh { app.pending_deliveries_refresh = false; let filter = app.deliveries.filter.as_str().to_string(); @@ -2040,6 +2065,19 @@ pub async fn run() -> anyhow::Result<()> { app.modals.reply_queue.set_error(e.to_string()); app.status_message = Some(format!("Reply queue load failed: {e}")); } + AsyncResult::StoredDraftsLoaded(Ok(drafts)) => { + let count = drafts.len(); + app.modals.drafts.set_drafts(drafts); + app.status_message = Some(if count == 0 { + "No saved drafts".into() + } else { + format!("{count} draft(s)") + }); + } + AsyncResult::StoredDraftsLoaded(Err(e)) => { + app.modals.drafts.set_error(e.to_string()); + app.status_message = Some(format!("Drafts load failed: {e}")); + } AsyncResult::DeliveriesList(Ok(rows)) => { let count = rows.len(); app.deliveries.set_rows(rows); @@ -2604,6 +2642,18 @@ pub async fn run() -> anyhow::Result<()> { AsyncResult::ComposeReady(Err(e)) => { app.status_message = Some(format!("Compose error: {e}")); } + AsyncResult::DraftEditReady(Ok(data)) => { + let status = run_with_terminal_suspended(&mut terminal, &mut events, || { + let editor = mxr_compose::editor::resolve_editor(None); + std::process::Command::new(&editor) + .arg(&data.path) + .status() + }); + handle_draft_edit_status(&mut app, &data, status, &bg).await; + } + AsyncResult::DraftEditReady(Err(e)) => { + app.status_message = Some(format!("Edit draft error: {e}")); + } AsyncResult::ReplyContextWarmed { message_id, reply, diff --git a/crates/tui/src/runner/tests/accounts_and_delivery.rs b/crates/tui/src/runner/tests/accounts_and_delivery.rs index 28520297..94248bc0 100644 --- a/crates/tui/src/runner/tests/accounts_and_delivery.rs +++ b/crates/tui/src/runner/tests/accounts_and_delivery.rs @@ -695,6 +695,76 @@ fn reply_queue_enter_starts_reply_compose_for_selected_message() { ); } +fn test_draft(subject: &str) -> Draft { + let now = chrono::Utc::now(); + Draft { + id: DraftId::new(), + account_id: AccountId::new(), + reply_headers: None, + intent: DraftIntent::New, + to: vec![Address { + name: None, + email: "alice@example.com".into(), + }], + cc: vec![], + bcc: vec![], + subject: subject.into(), + body_markdown: "Body.".into(), + attachments: vec![], + inline_calendar_reply: None, + created_at: now, + updated_at: now, + } +} + +#[test] +fn open_stored_drafts_opens_modal_and_flags_refresh() { + let mut app = App::new(); + + app.apply(Action::OpenStoredDrafts); + + assert!(app.modals.drafts.visible); + assert!(app.modals.drafts.loading); + assert!( + app.pending_drafts_refresh, + "opening the drafts modal should schedule a Request::ListDrafts refresh" + ); + + let subjects = ["Q4 plan", "Follow up"]; + let drafts: Vec = subjects.iter().map(|s| test_draft(s)).collect(); + app.modals.drafts.set_drafts(drafts); + assert!(!app.modals.drafts.loading); + assert_eq!( + app.modals + .drafts + .drafts + .iter() + .map(|d| d.subject.as_str()) + .collect::>(), + subjects + ); +} + +#[test] +fn stored_drafts_edit_key_queues_pending_edit_for_selected_draft() { + let mut app = App::new(); + let drafts = vec![test_draft("First"), test_draft("Second")]; + let selected_id = drafts[1].id.clone(); + app.modals.drafts.open_loading(); + app.modals.drafts.set_drafts(drafts); + app.modals.drafts.select_next(); + + let action = app.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!(action, Some(Action::StoredDraftsModalEdit)); + app.apply(action.unwrap()); + + assert!(!app.modals.drafts.visible); + assert_eq!( + app.pending_draft_edit_request.as_ref().map(|d| &d.id), + Some(&selected_id) + ); +} + #[test] fn compose_blank_recipient_advances_to_subject_modal() { let mut app = App::new(); diff --git a/crates/tui/src/ui/command_palette.rs b/crates/tui/src/ui/command_palette.rs index d1b8fb54..199e100d 100644 --- a/crates/tui/src/ui/command_palette.rs +++ b/crates/tui/src/ui/command_palette.rs @@ -433,6 +433,12 @@ pub fn default_commands() -> Vec { action: Action::OpenSnippets, category: "Compose".into(), }, + PaletteCommand { + label: "Drafts".into(), + shortcut: "gE".into(), + action: Action::OpenStoredDrafts, + category: "Compose".into(), + }, PaletteCommand { label: "Unsubscribe".into(), shortcut: "D".into(), diff --git a/crates/tui/src/ui/drafts_modal.rs b/crates/tui/src/ui/drafts_modal.rs new file mode 100644 index 00000000..94f0d53b --- /dev/null +++ b/crates/tui/src/ui/drafts_modal.rs @@ -0,0 +1,236 @@ +use super::centered_rect; +use crate::app::DraftsModalState; +use crate::theme::Theme; +use mxr_compose::draft_codec::format_addresses; +use ratatui::layout::Margin; +use ratatui::prelude::*; +use ratatui::widgets::*; + +const MODAL_WIDTH_PERCENT: u16 = 80; +const MODAL_HEIGHT_PERCENT: u16 = 70; + +pub fn draw(frame: &mut Frame, area: Rect, state: &DraftsModalState, theme: &Theme) { + if !state.visible { + return; + } + + let modal_area = centered_rect(MODAL_WIDTH_PERCENT, MODAL_HEIGHT_PERCENT, area); + Clear.render(modal_area, frame.buffer_mut()); + + let title = " Drafts — ↑/↓ navigate · Enter/e edit · Esc close "; + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.accent)) + .style(Style::default().bg(theme.modal_bg)); + let inner = block.inner(modal_area); + frame.render_widget(block, modal_area); + + if let Some(message) = &state.error { + let paragraph = Paragraph::new(format!("Failed to load drafts: {message}")) + .style(Style::default().fg(theme.error)) + .wrap(Wrap { trim: true }); + frame.render_widget(paragraph, inner.inner(Margin::new(1, 1))); + return; + } + + if state.loading { + let paragraph = Paragraph::new("Loading drafts...") + .style(Style::default().fg(theme.text_muted)) + .alignment(Alignment::Center); + frame.render_widget(paragraph, inner.inner(Margin::new(1, 1))); + return; + } + + if state.drafts.is_empty() { + let paragraph = + Paragraph::new("No saved drafts.\n\nDrafts you save from Compose (`c`) show up here.") + .style(Style::default().fg(theme.text_muted)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, inner.inner(Margin::new(2, 2))); + return; + } + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) + .split(inner); + + let items: Vec = state + .drafts + .iter() + .enumerate() + .map(|(idx, draft)| { + let style = if idx == state.selected_index { + Style::default() + .bg(theme.selection_bg) + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text_primary) + }; + let subject = if draft.subject.trim().is_empty() { + "(no subject)" + } else { + draft.subject.as_str() + }; + let to = format_addresses(&draft.to); + let label = if to.is_empty() { + format!(" {subject}") + } else { + format!(" {subject} · {to}") + }; + ListItem::new(label).style(style) + }) + .collect(); + let list = List::new(items).block( + Block::default() + .title(" Saved ") + .borders(Borders::RIGHT) + .border_style(Style::default().fg(theme.text_muted)), + ); + frame.render_widget(list, chunks[0]); + + let detail_area = chunks[1].inner(Margin::new(1, 0)); + if let Some(draft) = state.selected() { + let label_style = Style::default().fg(theme.text_muted); + let mut lines = vec![ + Line::from(vec![ + Span::styled("Subject: ", label_style), + Span::raw(if draft.subject.trim().is_empty() { + "(no subject)".to_string() + } else { + draft.subject.clone() + }), + ]), + Line::from(vec![ + Span::styled("To: ", label_style), + Span::raw(format_addresses(&draft.to)), + ]), + ]; + if !draft.cc.is_empty() { + lines.push(Line::from(vec![ + Span::styled("Cc: ", label_style), + Span::raw(format_addresses(&draft.cc)), + ])); + } + lines.push(Line::from(vec![ + Span::styled("Updated: ", label_style), + Span::raw(draft.updated_at.format("%Y-%m-%d %H:%M").to_string()), + ])); + lines.push(Line::from("")); + lines.push(Line::from(draft.body_markdown.clone())); + + let paragraph = Paragraph::new(lines) + .style(Style::default().fg(theme.text_primary)) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, detail_area); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{DateTime, Utc}; + use mxr_core::id::{AccountId, DraftId}; + use mxr_core::types::Address; + use mxr_core::Draft; + use mxr_test_support::render_to_string; + + fn draft(subject: &str, to_email: &str, body: &str) -> Draft { + let now: DateTime = DateTime::from_timestamp(1_700_000_000, 0).unwrap(); + Draft { + id: DraftId::new(), + account_id: AccountId::new(), + reply_headers: None, + intent: mxr_core::DraftIntent::New, + to: vec![Address { + name: None, + email: to_email.to_string(), + }], + cc: vec![], + bcc: vec![], + subject: subject.to_string(), + body_markdown: body.to_string(), + attachments: vec![], + inline_calendar_reply: None, + created_at: now, + updated_at: now, + } + } + + #[test] + fn draws_loading_placeholder_while_request_in_flight() { + let mut state = DraftsModalState::default(); + state.open_loading(); + let snapshot = render_to_string(80, 20, |frame| { + draw(frame, Rect::new(0, 0, 80, 20), &state, &Theme::default()); + }); + assert!( + snapshot.contains("Loading drafts..."), + "loading placeholder must surface while request is in-flight; got:\n{snapshot}" + ); + } + + #[test] + fn empty_state_points_at_compose_key() { + let mut state = DraftsModalState::default(); + state.open_loading(); + state.set_drafts(vec![]); + let snapshot = render_to_string(80, 20, |frame| { + draw(frame, Rect::new(0, 0, 80, 20), &state, &Theme::default()); + }); + assert!( + snapshot.contains("No saved drafts"), + "empty state copy must surface; got:\n{snapshot}", + ); + } + + #[test] + fn renders_subject_and_recipient_in_list_and_detail() { + let mut state = DraftsModalState::default(); + state.open_loading(); + state.set_drafts(vec![draft( + "Q4 plan", + "alice@example.com", + "Draft body text.", + )]); + + let snapshot = render_to_string(100, 20, |frame| { + draw(frame, Rect::new(0, 0, 100, 20), &state, &Theme::default()); + }); + assert!( + snapshot.contains("Q4 plan"), + "list must render draft subject; got:\n{snapshot}", + ); + assert!( + snapshot.contains("alice@example.com"), + "detail pane must render recipient; got:\n{snapshot}", + ); + assert!( + snapshot.contains("Draft body text."), + "detail pane must render body preview; got:\n{snapshot}", + ); + } + + #[test] + fn select_next_wraps() { + let mut state = DraftsModalState::default(); + state.open_loading(); + state.set_drafts(vec![draft("a", "a@x.com", "a"), draft("b", "b@x.com", "b")]); + state.select_next(); + assert_eq!(state.selected_index, 1); + state.select_next(); + assert_eq!(state.selected_index, 0); + } + + #[test] + fn select_prev_wraps_at_zero() { + let mut state = DraftsModalState::default(); + state.open_loading(); + state.set_drafts(vec![draft("a", "a@x.com", "a"), draft("b", "b@x.com", "b")]); + state.select_prev(); + assert_eq!(state.selected_index, 1); + } +} diff --git a/crates/tui/src/ui/mod.rs b/crates/tui/src/ui/mod.rs index 437ba750..ba55ab8e 100644 --- a/crates/tui/src/ui/mod.rs +++ b/crates/tui/src/ui/mod.rs @@ -12,6 +12,7 @@ pub mod compose_picker; pub mod deliveries_page; pub mod diagnostics_page; pub mod draft_options_modal; +pub mod drafts_modal; pub mod error_modal; pub mod expert_modal; pub mod help_modal; diff --git a/crates/web/src/routes_v6.rs b/crates/web/src/routes_v6.rs index 8ec3965c..7e6cb70d 100644 --- a/crates/web/src/routes_v6.rs +++ b/crates/web/src/routes_v6.rs @@ -1942,20 +1942,39 @@ async fn send_stored_draft( passthrough(response) } +/// Upsert-by-id: editing an existing stored draft (loaded via `GetDraft`) +/// must save in place rather than mint a duplicate row, so this tries +/// `UpdateDraft` first and falls back to `SaveDraft` (insert) only when the +/// daemon reports the id doesn't exist yet — i.e. a genuinely new draft. async fn save_draft_local( State(state): State, headers: HeaderMap, Query(auth): Query, Json(draft): Json, ) -> Result, BridgeError> { - let response = dispatch( + match dispatch( &state, &headers, auth.token.as_deref(), - Request::SaveDraft { draft }, + Request::UpdateDraft { + draft: draft.clone(), + }, ) - .await?; - passthrough(response) + .await + { + Ok(response) => passthrough(response), + Err(BridgeError::Ipc(message)) if message.to_lowercase().contains("not found") => { + let response = dispatch( + &state, + &headers, + auth.token.as_deref(), + Request::SaveDraft { draft }, + ) + .await?; + passthrough(response) + } + Err(err) => Err(err), + } } async fn delete_draft_stored(