summaryrefslogtreecommitdiffstats
path: root/src/mainwindow.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-24 12:41:46 +0200
committerDanilo M. <danix@danix.xyz>2026-08-24 12:41:46 +0200
commit58f13ad9d78a07aab1d683462834a2493078744d (patch)
treebd2273b161b214b986c11a8d00d3e20138c9f47b /src/mainwindow.cpp
parentb0d612c8ea232674ac0734b1121cfd0bb50d532b (diff)
downloadqtmaildir-58f13ad9d78a07aab1d683462834a2493078744d.tar.gz
qtmaildir-58f13ad9d78a07aab1d683462834a2493078744d.zip
feat(compose): open a draft to finish it
Item 153. DraftStore had a write() and no reader, and nothing opened a composer from an existing message, so a draft rendered like ordinary mail and could never be finished or sent. ComposeContextBuilder::forDraft() reads one back. A new Kind::Draft seeds every field verbatim: the subject takes no Re:/Fwd: prefix, and the body goes in exactly as it was left, with none of seedBody()'s quote framing. It is reachable by double-click and by an edit_draft action in the Message menu. Three things the shape of this depends on. A resumed draft must OWN its file. Maildir has no in-place edit, so an autosave writes a new file and unlinks the old one; a composer that did not know its own path would leave the original behind and one message would become two. ComposeContext::draftPath carries it into m_draftPath, which the autosave already knew how to replace. MimeParser had no bcc, and nothing had ever needed one. MessageBuilder writes Bcc into the draft file deliberately and explains why, so a resumed draft that ignored it would drop every blind recipient from the message the user then finishes and sends, reporting nothing. edit_draft is gated on the file being inside a configured drafts folder, matched on the PATH. A `draft` tag is not enough: notmuch surfaces the Maildir D flag as one, and a message flagged by another client sits in the inbox. Offered on ordinary mail, the composer would own a file it did not write and the first autosave would delete a received message. And a live defect found on the way, which is most of why this took as long as it did. updateComposeActions() ran only from onSelectionChanged. Both signals fire for an ordinary click, so nothing had noticed; but running a query and setting the current index emits currentRowChanged ALONE, so the enablement was computed against the previously selected row. Edit draft stayed disabled on a draft selected that way, and the reply family had the same blind spot with no test that could see it. Now connected to both. Reading currentRowChanged is safe here for the reason CLAUDE.md gives: it answers "which row is current", and no count is read. WorkerBackedWindow::AccountSpec gains a drafts field, which the two new tests need and which no fixture could express before.
Diffstat (limited to 'src/mainwindow.cpp')
-rw-r--r--src/mainwindow.cpp123
1 files changed, 123 insertions, 0 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index da18869..eb41da9 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -875,6 +875,19 @@ void MainWindow::buildUi()
&QItemSelectionModel::currentRowChanged,
this, &MainWindow::onThreadSelected);
+ // Also on currentRowChanged, and NOT only from onSelectionChanged, which
+ // is where the reply family is answered. Both signals fire for an ordinary
+ // click, but a selection that does not CHANGE emits only this one: running
+ // a query and setting the current index reaches here and never the other,
+ // so the enablement was computed against the previously selected row.
+ // Measured: Edit draft stayed disabled on a draft selected that way.
+ //
+ // Safe on currentRowChanged, which CLAUDE.md restricts to "which row is
+ // current": that is exactly the question here, and no count is read.
+ connect(m_threadView->selectionModel(),
+ &QItemSelectionModel::currentRowChanged, this,
+ [this]() { updateComposeActions(); });
+
// Separate from currentRowChanged: a selection can grow without current
// moving at all. Ctrl+click adds a row and leaves current where it was, and
// selectAll() emits no currentRowChanged whatsoever (verified against
@@ -970,6 +983,28 @@ void MainWindow::composeReply(ComposeContext::Kind kind, bool quote)
requestMessageForCompose(scope.messageIds.first(), kind, quote);
}
+void MainWindow::editDraft()
+{
+ editDraftAt(m_threadView->currentIndex());
+}
+
+void MainWindow::editDraftAt(const QModelIndex &index)
+{
+ // messageScopeFor(), like composeReply(): a thread row means the one
+ // message its card shows.
+ const ActionScope scope = m_model->messageScopeFor({ index });
+ if (scope.messageIds.isEmpty()) {
+ showTransientStatus(tr("No message is selected"));
+ return;
+ }
+
+ // Through the worker for its path, never from the model: the model's path
+ // comes from the query, and a draft is rewritten by every autosave, so a
+ // row that has not been re-queried names a file that no longer exists.
+ requestMessageForCompose(scope.messageIds.first(),
+ ComposeContext::Kind::Draft, false);
+}
+
void MainWindow::requestMessageForCompose(const QString &messageId,
ComposeContext::Kind kind,
bool quote)
@@ -990,6 +1025,24 @@ void MainWindow::requestMessageForCompose(const QString &messageId,
void MainWindow::openComposerFor(const MessageRef &ref,
ComposeContext::Kind kind, bool quote)
{
+ // A draft is RESUMED rather than answered: nothing is derived from it,
+ // and the composer takes ownership of its file. Handled before the reply
+ // machinery below, none of which applies (item 153).
+ if (kind == ComposeContext::Kind::Draft) {
+ const ComposeContext draft =
+ ComposeContextBuilder::forDraft(m_config, ref.filePath);
+ if (draft.kind != ComposeContext::Kind::Draft) {
+ showTransientStatus(tr("That draft could not be read"));
+ return;
+ }
+ if (draft.accountKey.isEmpty()) {
+ showTransientStatus(tr("No account is configured to send"));
+ return;
+ }
+ openComposer(draft);
+ return;
+ }
+
MimeParser parser;
const ParsedMessage original = parser.parse(ref.filePath);
if (!original.ok) {
@@ -1155,6 +1208,50 @@ void MainWindow::markComposersDirtyForTest()
}
}
+bool MainWindow::currentMessageIsADraft() const
+{
+ return indexIsADraft(m_threadView->currentIndex());
+}
+
+bool MainWindow::indexIsADraft(const QModelIndex &current) const
+{
+ if (m_mailRoot.isEmpty())
+ return false;
+
+ if (!current.isValid())
+ return false;
+
+ QString path;
+ if (m_model->isMessageRow(current))
+ path = m_model->messageAt(current).filePath;
+ else
+ path = m_model->threadFor(current).firstMessagePath;
+ if (path.isEmpty())
+ return false;
+
+ // ThreadSummary::firstMessagePath is RELATIVE to the mail root and
+ // MessageNode::filePath is ABSOLUTE, the asymmetry accountForCurrentMessage()
+ // documents. Compared as a resolved absolute path against each account's
+ // drafts folder.
+ const QString absolute = QDir::isAbsolutePath(path)
+ ? path
+ : QDir(m_mailRoot).absoluteFilePath(path);
+
+ for (const Account &account : m_config.accounts()) {
+ if (account.drafts.isEmpty())
+ continue;
+ const QString folder = QDir(m_mailRoot).absoluteFilePath(
+ account.maildir + QLatin1Char('/') + account.drafts);
+ // A path comparison with a separator, never startsWith() on the bare
+ // folder: "/mail/acct/Drafts-old/cur/x" starts with "/mail/acct/Drafts"
+ // and is a different folder. This is the rule the attachment save path
+ // already follows.
+ if (absolute.startsWith(folder + QLatin1Char('/')))
+ return true;
+ }
+ return false;
+}
+
QString MainWindow::accountForCurrentMessage() const
{
if (m_mailRoot.isEmpty())
@@ -1214,6 +1311,17 @@ void MainWindow::updateComposeActions()
action->setEnabled(canReply);
}
+ // Edit draft is offered only on a row that IS a draft. On ordinary mail
+ // it would open a composer owning a file it did not write, and the first
+ // autosave replaces that file: editing a received message would delete it.
+ //
+ // Answered from the path, like accountForCurrentMessage() above, because
+ // the folder is what makes a draft a draft. A `draft` tag is not enough:
+ // notmuch surfaces the Maildir D flag as one, and a message flagged by
+ // another client sits in the inbox rather than in the drafts folder.
+ if (QAction *edit = m_actions.value(QStringLiteral("edit_draft")))
+ edit->setEnabled(currentMessageIsADraft());
+
// The ribbon appears only when an account was identified AND it cannot
// send. An unidentified account is not a receive-only one: it is a message
// whose file no account owns, and naming no account in a ribbon that
@@ -1717,6 +1825,9 @@ void MainWindow::registerActions()
addAction(QStringLiteral("forward"), tr("&Forward"),
tr("Forward the displayed message"),
[this]() { composeReply(ComposeContext::Kind::Forward, true); });
+ addAction(QStringLiteral("edit_draft"), tr("&Edit draft"),
+ tr("Open the selected draft in a composer to finish it"),
+ [this]() { editDraft(); });
addAction(QStringLiteral("save_message"), tr("Sa&ve message as..."),
tr("Write the raw message to a file"),
[this]() { saveDisplayedMessage(); });
@@ -1764,6 +1875,7 @@ void MainWindow::buildMenus()
messageMenu->addAction(m_actions.value(QStringLiteral("reply_all")));
messageMenu->addAction(m_actions.value(QStringLiteral("reply_no_quote")));
messageMenu->addAction(m_actions.value(QStringLiteral("forward")));
+ messageMenu->addAction(m_actions.value(QStringLiteral("edit_draft")));
messageMenu->addSeparator();
messageMenu->addAction(m_actions.value(QStringLiteral("save_message")));
messageMenu->addSeparator();
@@ -1875,6 +1987,7 @@ void MainWindow::buildMenus()
{ QStringLiteral("select_all"), QStringLiteral("edit-select-all") },
{ QStringLiteral("clear_pane"), QStringLiteral("edit-clear") },
{ QStringLiteral("clear_selection"), QStringLiteral("edit-clear-all") },
+ { QStringLiteral("edit_draft"), QStringLiteral("document-edit") },
{ QStringLiteral("toggle_html"), QStringLiteral("text-html") },
{ QStringLiteral("load_remote"), QStringLiteral("image-loading") },
{ QStringLiteral("message_details"), QStringLiteral("dialog-information") },
@@ -4111,6 +4224,16 @@ void MainWindow::onRowDoubleClicked(const QModelIndex &index)
if (!index.isValid())
return;
+ // A draft opens in the COMPOSER, not in a thread view of itself: it is an
+ // unfinished message, and looking at one rendered is not what the gesture
+ // means (item 153). Checked before the thread route below, which is what
+ // every other row does.
+ // The CLICKED row, which is not necessarily the current one.
+ if (indexIsADraft(index)) {
+ editDraftAt(index);
+ return;
+ }
+
// The whole thread in every case, and the double-clicked row's own message
// in the pane. A reply therefore drills to its THREAD with itself selected,
// never to itself alone: "double click on a reply in a thread should still