summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-04 12:36:21 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 12:55:05 +0200
commitf0e99dceb5a395eb542a6a67a0b56b7bb33a2474 (patch)
treebd7cfab053395c507ad282fbebaf270ac1d6e2c2
parent17c75c88ac91fa010de779b4c53883f1f58f0308 (diff)
downloadqtmaildir-f0e99dceb5a395eb542a6a67a0b56b7bb33a2474.tar.gz
qtmaildir-f0e99dceb5a395eb542a6a67a0b56b7bb33a2474.zip
feat(sync): dismissable log pane, progress bar, and a skip that is not a failure
Four changes to the sync UI, three of them from using it. The log pane could not be dismissed. It is hidden at construction and shown on failure, and nothing ever hid it again, so a single failed sync left it on screen until the application restarted. It now sits in a container with its own Close button, and is 200px rather than 120, because mbsync's output is wide and repetitive and the shorter pane showed too little of it to read. It still appears only on failure, which the user confirmed is what they want. A sync gives no feedback while it runs. The status bar now carries an indeterminate progress bar for the duration, and the Sync button is disabled rather than left looking live. The bar is indeterminate on purpose: mbsync reports no percentage and the script's output is unstructured, so a bar filling left to right would be inventing a fraction nobody knows. The log is also cleared at the start of each run, since leaving the previous run's lines in place makes a stale failure look like the current one. The lock skip was reported as a failure. mailsync.sh exits when another run holds the lock, and that was exit 1, which qtmaildir reads as "sync failed": it showed the log pane and, on the exit path, told the user their changes were still unsynced. With a cron timer every ten minutes, a click landing inside a run is routine and none of that is true. The script now exits 75 (EX_TEMPFAIL) and the window reports it as its own case, saying a sync is already running. On the exit path it stays open and says plainly that the other run is most likely carrying the changes over but that this window cannot see it finish, rather than guessing either way. That last hedge is what item 27 records: the application cannot see a sync it did not start. The user chose continuous polling of the lock file over the narrower "only while quitting" version, and the entry notes that the lock is already the signal, so no status file is needed, and that a kernel lock cannot go stale where a written file can. Verified against stub binaries: a second run while the lock is held exits 75 and says SKIPPED, while the run holding it completes at 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--README.md11
-rwxr-xr-xassets/mailsync.sh9
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md68
-rw-r--r--src/mainwindow.cpp93
-rw-r--r--src/mainwindow.h25
5 files changed, 197 insertions, 9 deletions
diff --git a/README.md b/README.md
index bf052b3..5b9a3e6 100644
--- a/README.md
+++ b/README.md
@@ -306,6 +306,17 @@ Two things any replacement has to get right, both learned the hard way:
it reports success, clears the unsynced-changes count, and will quit on it
during a sync-on-exit. The previous version ended in an unconditional
`exit 0`, so a failed `mbsync` was indistinguishable from a clean run.
+- **Exit 75 when another run holds the lock**, rather than 1. A skip is not a
+ failure: the other run is doing the work, and with a timer every ten minutes
+ a click landing inside one is routine. qtmaildir reports 75 as "a sync is
+ already running" and leaves the log pane alone, where any other non-zero code
+ raises an error.
+
+While a sync this window started is running, the status bar shows an
+indeterminate progress bar. It is deliberately not a percentage: `mbsync`
+reports no progress, so a bar filling left to right would be inventing one. A
+sync started outside the application, by your cron timer, is not currently
+visible here at all.
## Unsynced changes
diff --git a/assets/mailsync.sh b/assets/mailsync.sh
index d5f51ee..2134439 100755
--- a/assets/mailsync.sh
+++ b/assets/mailsync.sh
@@ -51,9 +51,12 @@ if ! flock -n 200; then
# Both streams again: a caller that skipped because the cron run holds
# the lock needs to be told, not left with silence and an error code.
msg="$(date -Iseconds) === SKIPPED: previous run still in progress ==="
- echo "$msg" >> "$LOGFILE"
- echo "$msg" >&2
- exit 1
+ echo "$msg" | tee -a "$LOGFILE" >&2
+ # 75 (EX_TEMPFAIL), not 1. A skip is not a failure: the other run is
+ # doing the work. qtmaildir reports 1 as "sync failed" and shows its log
+ # pane, which is wrong for a click that landed during the cron run, and
+ # cron fires every ten minutes so that overlap is routine.
+ exit 75
fi
# Statuses are written to files rather than shell variables because the
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 4df48dd..5fcd6d8 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -65,6 +65,7 @@ taking that too literally.
| 24 | No right-click actions on the thread list | discoverability | S | open |
| 25 | No select-all, and bulk actions are undiscoverable | workflow | S | open |
| 26 | No way to add or remove an arbitrary tag from the UI | workflow | S | **done** |
+| 27 | The UI cannot see a sync it did not start | feedback | S | open |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -1192,6 +1193,73 @@ character. The test was corrected, not the validator.
Rendered and inspected rather than only asserted.
+## 27. The UI cannot see a sync it did not start
+
+**Observed (user, 2026-08-04), as a question:** "will the status bar be aware of
+cronjob fired syncs?" It is not. The progress bar and the busy state are driven
+by `MailSync`'s own `QProcess`, so they know only about syncs this window
+started. The user's cron timer fires every ten minutes and the application is
+blind to it.
+
+**The lock file is already the signal; no new file is needed.** `mailsync.sh`
+does `exec 200>"$LOCKFILE"` and then `flock -n 200` on `/tmp/mbsync.lock`.
+Another process can test whether that lock is held, without disturbing it, by
+opening the same path on its own descriptor and attempting `flock(LOCK_EX |
+LOCK_NB)`: the attempt fails exactly when someone holds it, and succeeds
+otherwise, in which case the tester drops it again immediately.
+
+This is better than the status file first proposed. A kernel lock **cannot go
+stale**: it is released when the holding process dies, however it dies. A status
+file written by the script survives a `kill -9` and would leave the UI claiming
+a sync is running forever, which then needs a heartbeat and a staleness
+timeout, none of which the lock needs.
+
+**Decided (user, 2026-08-04): poll continuously, not only while quitting.** A
+narrower version that polled only during the exit prompt was offered and
+declined: the user wants the status bar informed at all times, not only at the
+one moment it changes a decision.
+
+**Approach.**
+
+- A `syncLockHeld()` helper: `open()` the lock path, `flock(LOCK_EX|LOCK_NB)`,
+ close. Held when the attempt fails with `EWOULDBLOCK`.
+- A `QTimer` polling it. **One second is finer than this needs**; a sync runs
+ for tens of seconds, so two seconds is plenty and halves a wakeup that never
+ stops.
+- When the lock is held and `MailSync` is NOT the holder, show the busy state
+ with wording that says so: "Syncing (started elsewhere)". The existing
+ `setSyncBusy()` covers the widgets; this adds a third state between "idle"
+ and "we are syncing".
+- On the exit path, this replaces a hedge with a fact. Today, quitting while
+ cron syncs says the window "cannot see it finish". With the lock watched it
+ can wait for the lock to clear and then quit, which is what the user actually
+ wants to happen.
+
+**Verify before building, both cheap and both the kind of assumption this
+project has been bitten by twice:**
+
+- **That the lock is observable at all.** `flock` semantics across processes,
+ where the holder opened the file with `exec 200>`, are an assumption about
+ Linux behaviour, not a certainty. A twenty-line probe settles it. Do not
+ design on top of it unproven.
+- **That polling it cannot disturb notmuch.** `mailsync.sh` runs `notmuch new`,
+ and notmuch's write lock is process-exclusive. Touching `/tmp/mbsync.lock`
+ should be unrelated, but the interaction is worth one look rather than an
+ assumption.
+
+**Constraints.**
+
+- The lock path becomes a contract between the script and the application,
+ where today the application knows nothing about it. It has to be documented
+ on both sides, and the script cannot move or rename it casually afterwards.
+- The poll must not run a query or touch the database. It reports what another
+ process is doing; the existing `runCurrentQuery()` on a completed sync of our
+ own already handles refreshing, and a cron sync that finishes will be picked
+ up the next time the user runs a query.
+- Do not report an externally-started sync as one this window can cancel. The
+ Sync button should be disabled while the lock is held, since starting one
+ would only produce the `EX_TEMPFAIL` skip.
+
---
## Deferred, unsized, or split out
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 000be85..0a9f9d8 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -36,6 +36,7 @@
#include <QMenuBar>
#include <QMessageBox>
#include <QPlainTextEdit>
+#include <QProgressBar>
#include <QPushButton>
#include <QSettings>
#include <QSplitter>
@@ -193,6 +194,8 @@ void MainWindow::closeEvent(QCloseEvent *event)
if (box.clickedButton() == sync) {
if (m_sync->start()) {
m_syncingForExit = true;
+ m_syncLog->clear();
+ setSyncBusy(true);
m_statusLabel->setText(tr("Syncing before quitting..."));
event->ignore();
return;
@@ -212,6 +215,8 @@ void MainWindow::closeEvent(QCloseEvent *event)
} else if (m_config.syncOnExit() == Config::SyncOnExit::Always) {
if (m_sync->start()) {
m_syncingForExit = true;
+ m_syncLog->clear();
+ setSyncBusy(true);
m_statusLabel->setText(tr("Syncing before quitting..."));
event->ignore();
return;
@@ -330,6 +335,18 @@ void MainWindow::buildUi()
m_pendingLabel->hide();
statusBar()->addPermanentWidget(m_pendingLabel);
+ // Indeterminate: setRange(0, 0). A sync has no measurable progress, since
+ // mbsync reports no percentage and the script's output is unstructured, so
+ // a bar filling left to right would be inventing a fraction. This one
+ // animates to say "working, duration unknown".
+ m_syncProgress = new QProgressBar(this);
+ m_syncProgress->setObjectName(QStringLiteral("syncProgress"));
+ m_syncProgress->setRange(0, 0);
+ m_syncProgress->setTextVisible(false);
+ m_syncProgress->setMaximumWidth(120);
+ m_syncProgress->hide();
+ statusBar()->addPermanentWidget(m_syncProgress);
+
// Query row.
auto *queryRow = new QHBoxLayout;
m_accountBox = new QComboBox(central);
@@ -361,10 +378,35 @@ void MainWindow::buildUi()
connect(m_markReadTimer, &QTimer::timeout,
this, &MainWindow::markCurrentThreadRead);
- m_syncLog = new QPlainTextEdit(central);
+ // The pane and its close button travel together: a QPlainTextEdit has
+ // nowhere to put one, and a pane that appears on a failed sync and can
+ // never be dismissed is worse than one that does not appear at all.
+ m_syncLogPane = new QWidget(central);
+ m_syncLogPane->setObjectName(QStringLiteral("syncLogPane"));
+ auto *syncLogLayout = new QVBoxLayout(m_syncLogPane);
+ syncLogLayout->setContentsMargins(0, 0, 0, 0);
+ syncLogLayout->setSpacing(2);
+
+ auto *syncLogHeader = new QHBoxLayout;
+ syncLogHeader->addWidget(new QLabel(tr("Sync output"), m_syncLogPane));
+ syncLogHeader->addStretch();
+
+ auto *closeSyncLog = new QPushButton(tr("Close"), m_syncLogPane);
+ closeSyncLog->setObjectName(QStringLiteral("closeSyncLog"));
+ closeSyncLog->setToolTip(tr("Hide the sync output until the next failure"));
+ connect(closeSyncLog, &QPushButton::clicked,
+ m_syncLogPane, &QWidget::hide);
+ syncLogHeader->addWidget(closeSyncLog);
+ syncLogLayout->addLayout(syncLogHeader);
+
+ m_syncLog = new QPlainTextEdit(m_syncLogPane);
m_syncLog->setReadOnly(true);
- m_syncLog->setMaximumHeight(120);
- m_syncLog->hide();
+ // 200 rather than 120: mbsync's output is wide and repetitive, and the
+ // shorter pane showed too little of it to read.
+ m_syncLog->setMaximumHeight(200);
+ syncLogLayout->addWidget(m_syncLog);
+
+ m_syncLogPane->hide();
m_syncButton = new QPushButton(tr("Sync"), central);
m_sync = new MailSync(m_config.syncCommand(), this);
@@ -374,8 +416,14 @@ void MainWindow::buildUi()
tr("No sync command configured ([sync] command in qtmaildir.conf)"));
}
connect(m_syncButton, &QPushButton::clicked, this, [this]() {
- if (!m_sync->start())
+ if (!m_sync->start()) {
m_statusLabel->setText(tr("Sync already running"));
+ return;
+ }
+ // Fresh run, fresh output: leaving the previous run's lines in place
+ // makes a stale failure look like the current one.
+ m_syncLog->clear();
+ setSyncBusy(true);
});
connect(m_sync, &MailSync::finished, this, &MainWindow::onSyncFinished);
connect(m_sync, &MailSync::outputReceived, this, [this](const QString &chunk) {
@@ -451,7 +499,7 @@ void MainWindow::buildUi()
m_splitter->setStretchFactor(1, 2);
layout->addWidget(m_splitter, 1);
- layout->addWidget(m_syncLog);
+ layout->addWidget(m_syncLogPane);
setCentralWidget(central);
@@ -1013,6 +1061,8 @@ void MainWindow::onWorkerError(const QString &message)
void MainWindow::onSyncFinished(bool success, int exitCode)
{
+ setSyncBusy(false);
+
if (success) {
// Only a SUCCESSFUL sync clears the count. Clearing on failure would
// assert the edits had reached the mail store when the sync is exactly
@@ -1033,9 +1083,28 @@ void MainWindow::onSyncFinished(bool success, int exitCode)
runCurrentQuery();
// A sync is the usual way new tags enter the database.
requestAllTags();
+ } else if (exitCode == kSyncSkippedExitCode) {
+ // Not a failure: another run holds the lock and is doing the work.
+ // The user's cron fires every ten minutes, so a click landing inside
+ // one is routine and must not raise an error or the log pane.
+ m_statusLabel->setText(tr("A sync is already running (started "
+ "elsewhere); this one was skipped"));
+
+ if (m_syncingForExit) {
+ // The other run is syncing, but this application cannot see when
+ // it finishes, so it cannot promise the changes are across. Leave
+ // the window open and say so rather than quitting on a guess.
+ m_syncingForExit = false;
+ QMessageBox::information(
+ this, tr("Sync already running"),
+ tr("Another sync was already in progress, so this one was "
+ "skipped. Your changes are most likely being carried over "
+ "by that run, but this window cannot see it finish, so it "
+ "has been left open."));
+ }
} else {
m_statusLabel->setText(tr("Sync failed (exit %1)").arg(exitCode));
- m_syncLog->show();
+ m_syncLogPane->show();
if (m_syncingForExit) {
// Do NOT quit: the edits are still unsynced and quitting now would
@@ -1073,6 +1142,18 @@ void MainWindow::onTagsApplied(const TagChange &change)
}
}
+void MainWindow::setSyncBusy(bool busy)
+{
+ m_syncProgress->setVisible(busy);
+ // Disabled rather than left clickable: MailSync::start() already refuses a
+ // second run, but a button that looks live and does nothing is worse than
+ // one that shows it is unavailable.
+ m_syncButton->setEnabled(!busy && m_sync && m_sync->isAvailable());
+
+ if (busy)
+ m_statusLabel->setText(tr("Syncing..."));
+}
+
void MainWindow::updatePendingIndicator()
{
if (m_pendingEdits <= 0) {
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 7897038..d1f7b1c 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -39,6 +39,7 @@ class QPushButton;
class QComboBox;
class QPlainTextEdit;
class QSplitter;
+class QProgressBar;
class QTimer;
class ThreadListModel;
@@ -66,6 +67,15 @@ public:
/// cid: references from resolving to another's.
static QString cidPrefixForIndex(int index);
+ /// What the sync command returns when another run already holds the lock.
+ ///
+ /// EX_TEMPFAIL from sysexits.h. A skip is not a failure: the other run is
+ /// doing the work, and with a cron timer every ten minutes a click landing
+ /// inside one is routine. Reporting it as an error would show a log pane
+ /// and an alarming status for a situation that needs neither.
+ /// `assets/mailsync.sh` is the reference implementation of this contract.
+ static constexpr int kSyncSkippedExitCode = 75;
+
/// Path of the machine-written UI state file. Deliberately not
/// Config::defaultPath(): the config is hand-edited and must never gain a
/// base64 geometry blob, nor be rewritten on exit (QSettings does not
@@ -134,6 +144,15 @@ private:
/// Redraws the unsynced-edits indicator from m_pendingEdits.
void updatePendingIndicator();
+ /// Shows or hides the "syncing" state: the progress bar and a disabled
+ /// Sync button.
+ ///
+ /// The bar is INDETERMINATE by design. mbsync reports no percentage and
+ /// the script's output is unstructured, so a bar that filled from left to
+ /// right would be inventing a fraction nobody knows. An indeterminate one
+ /// says "working, duration unknown", which is the truth.
+ void setSyncBusy(bool busy);
+
/// Opens the tag dialog on the current selection and applies its result.
///
/// The only route to an arbitrary tag: every other tag action writes a
@@ -185,6 +204,12 @@ private:
/// Says how many tag changes have not been seen to reach the mail store.
/// Hidden entirely at zero rather than reading "0 unsynced", which is noise.
QLabel *m_pendingLabel = nullptr;
+
+ /// Indeterminate, shown only while a sync runs. See setSyncBusy().
+ QProgressBar *m_syncProgress = nullptr;
+
+ /// Holds the sync log and its close button, so the pane can be dismissed.
+ QWidget *m_syncLogPane = nullptr;
QPlainTextEdit *m_syncLog = nullptr;
/// Action name (as used in [keys]) to the QAction implementing it. Owned