Skip to content

search: async background refresh with notmuch revision check#134

Open
klement wants to merge 2 commits into
akissinger:masterfrom
klement:search-async-refresh
Open

search: async background refresh with notmuch revision check#134
klement wants to merge 2 commits into
akissinger:masterfrom
klement:search-async-refresh

Conversation

@klement

@klement klement commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Avoids a full model reset when only a single thread's tags changed — instead updates just that row, so the selection and scroll position are preserved.
  • After a mail sync, checks the notmuch database revision counter and skips the refresh entirely if nothing changed.
  • Runs search panel refreshes asynchronously via QProcess so the UI is never blocked during large mailbox scans.

Together these changes make periodic sync refreshes nearly invisible to the user in the common case where no new mail arrived.

Test plan

  • Trigger a mail sync that produces new mail — panels refresh and show updated results
  • Trigger a mail sync with no changes — panels are not refreshed (check log output)
  • Tag a single message — only that row updates, selection stays on the current thread
  • With a large mailbox, verify the UI remains responsive during refresh

klement and others added 2 commits April 10, 2026 08:14
Use dataChanged for updates and beginRemoveRows for removals instead
of beginResetModel/endResetModel, so Qt only repaints the affected row
rather than rebuilding the entire view. Eliminates UI stalls on large
search results (e.g. 3500 threads).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
After each mail sync, check the notmuch database revision via
'notmuch count --lastmod' before doing any work. If the revision is
unchanged (no mail arrived), skip all refreshes entirely.

When mail does arrive, search panels refresh concurrently in the
background via QProcess so the UI thread is never blocked. A
_refreshing guard prevents duplicate refreshes; a second call while
one is in flight is silently dropped and the in-flight refresh still
finalises normally. Tab switches are instant since data is already
fresh by the time the user looks.

Other panel types (thread, compose) stay on the existing lazy-dirty
path and refresh synchronously on next focus.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

@laarmen laarmen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All in all a good idea but the refresh implementation needs to be more thought out to avoid duplication code.

Comment thread dodo/search.py
self.num_threads = len(self.d)
if contents:
# Update in place — no need to rebuild self.threads since
# thread IDs are stable in notmuch and cannot change.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: add a sanity check

                assert len(contents) == 1

Comment thread dodo/search.py
Comment on lines +69 to +107
def refresh_async(self, callback=None) -> None:
"""Refresh the model asynchronously using QProcess.

If a refresh is already in progress the call is ignored; the in-flight
process will still invoke *callback* once it finishes."""
if self._process is not None:
# Already running — attach the new callback so the caller still
# gets notified when the current process finishes.
if callback is not None:
self._process.finished.connect(lambda *_: callback())
return

logger.info("Beginning async search refresh for '%s'", self.q)
proc = QProcess()
self._process = proc

def on_finished(exit_code: int, _exit_status) -> None:
self._process = None
if exit_code == 0:
output = bytes(proc.readAllStandardOutput()).decode('utf-8')
try:
d = json.loads(output)
self.error_msg = None
self.beginResetModel()
self.d = d
self.threads = {t['thread']: i for i, t in enumerate(d)}
self.num_threads = len(d)
self.endResetModel()
except Exception as e:
logger.error("Search '%s': error parsing async result: %s", self.q, e)
else:
stderr = bytes(proc.readAllStandardError()).decode('utf-8')
self.error_msg = f"notmuch: {stderr}"
logger.error("Search '%s': async refresh failed: %s", self.q, stderr)
if callback is not None:
callback()

proc.finished.connect(on_finished)
proc.start('notmuch', ['search', '--format=json', self.q])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: this duplicates way too much code

Either make this the only way to do a full refresh, or at least factor out all the common code from both sync and async refreshes into helper methods.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants