From 39ab5db00665a361c8d63786645bbd6b06057697 Mon Sep 17 00:00:00 2001 From: Klement Sekera Date: Tue, 24 Mar 2026 10:59:36 +0100 Subject: [PATCH 1/2] search: avoid full model reset when refreshing a single thread 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 --- dodo/search.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/dodo/search.py b/dodo/search.py index bc69842..387b3bb 100644 --- a/dodo/search.py +++ b/dodo/search.py @@ -75,7 +75,6 @@ def refresh_thread(self, thread: QModelIndex|str): assert thread_id is not None logger.info("Search '%s': refreshing thread %s", self.q, thread_id) - self.beginResetModel() try: r = subprocess.run( ['notmuch', 'search', '--format=json', f'{self.q} AND thread:{thread_id}'], @@ -85,13 +84,23 @@ def refresh_thread(self, thread: QModelIndex|str): ) contents = json.loads(r.stdout) - self.d[row:row+1] = contents - self.threads = {thread['thread']: i for i,thread in enumerate(self.d)} - 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. + self.d[row] = contents[0] + left = self.index(row, 0) + right = self.index(row, self.columnCount() - 1) + self.dataChanged.emit(left, right) + logger.info("Search '%s': thread %s updated at row %d", self.q, thread_id, row) + else: + self.beginRemoveRows(QModelIndex(), row, row) + del self.d[row] + self.threads = {thread['thread']: i for i,thread in enumerate(self.d)} + self.num_threads = len(self.d) + self.endRemoveRows() + logger.info("Search '%s': thread %s removed from row %d", self.q, thread_id, row) except subprocess.CalledProcessError as e: self.error_msg = f"notmuch: {e.stderr}" - self.endResetModel() - logger.info("Model refreshed for '%s'", self.q) def refresh_num_threads(self): """Only refresh the number of threads in the search, not the underlying data""" From 4f94d35a75560294f18940176aed6941ed8dd0b9 Mon Sep 17 00:00:00 2001 From: Klement Sekera Date: Tue, 24 Mar 2026 11:49:38 +0100 Subject: [PATCH 2/2] search: async background refresh with notmuch revision check 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 --- dodo/app.py | 42 ++++++++++++++++++++++++++++-- dodo/search.py | 70 +++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/dodo/app.py b/dodo/app.py index e01369d..3747007 100644 --- a/dodo/app.py +++ b/dodo/app.py @@ -150,6 +150,9 @@ def __init__(self) -> None: signal.signal(signal.SIGINT, lambda *_: None) + # revision counter used to skip unnecessary refreshes after sync + self._last_revision: int = -1 + # open init_queries and make un-closeable # for query in settings.init_queries: @@ -321,8 +324,11 @@ def sync_mail(self, quiet: bool=True) -> None: self.sync_thread = t def done() -> None: - self.refresh_panels() - self.refresh_tab_titles() + rev = self._get_notmuch_revision() + if rev != self._last_revision: + self._last_revision = rev + self.refresh_panels_async() + self.refresh_tab_titles() if not quiet: title = self.main_window.windowTitle() self.main_window.setWindowTitle(title.replace(' [syncing]', '')) @@ -349,6 +355,38 @@ def refresh_tab_titles(self) -> None: if isinstance(w, panel.Panel): self.tabs.setTabText(i, w.title()) + def _get_notmuch_revision(self) -> int: + """Return the current notmuch database revision number. + + ``notmuch count --lastmod`` outputs COUNT\\tUUID\\tREVISION. The revision + increments on every database write, so comparing it before and after a + sync tells us whether any mail actually changed.""" + try: + r = subprocess.run(['notmuch', 'count', '--lastmod'], + capture_output=True, text=True, check=True) + return int(r.stdout.split()[2]) + except Exception: + return -1 + + def refresh_panels_async(self) -> None: + """Mark all panels dirty and start background async refreshes for search panels. + + Called after a sync that produced a database change. Search panels + refresh in the background so the UI is never blocked; other panel types + (thread, compose) stay dirty and refresh lazily on next focus.""" + + for i in range(self.num_panels()): + w = self.tabs.widget(i) + if isinstance(w, panel.Panel): + w.dirty = True + + for i in range(self.num_panels()): + w = self.tabs.widget(i) + if isinstance(w, search.SearchPanel): + w.refresh() # async — returns immediately, updates when done + + self.update_dock_badge() + def refresh_panels(self) -> None: """Refresh current panel and mark the others as out of date diff --git a/dodo/search.py b/dodo/search.py index 387b3bb..f49cc3a 100644 --- a/dodo/search.py +++ b/dodo/search.py @@ -19,7 +19,7 @@ from __future__ import annotations from typing import Optional, Any, overload, Literal -from PyQt6.QtCore import Qt, QAbstractItemModel, QModelIndex, QObject, QSettings +from PyQt6.QtCore import Qt, QAbstractItemModel, QModelIndex, QObject, QSettings, QProcess from PyQt6.QtWidgets import QTreeView, QWidget, QAbstractSlider, QVBoxLayout, QLabel from PyQt6.QtGui import QFont, QColor import subprocess @@ -46,6 +46,7 @@ def __init__(self, q: str) -> None: self.json_str = "" self.num_threads = 0 self.error_msg = None + self._process: QProcess | None = None self.refresh() def refresh(self) -> None: @@ -65,6 +66,46 @@ def refresh(self) -> None: self.num_threads = len(self.d) self.endResetModel() + 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]) + def refresh_thread(self, thread: QModelIndex|str): if isinstance(thread, str): thread_id = thread @@ -239,6 +280,8 @@ def __init__(self, a: app.Dodo, q: str, keep_open: bool=False, parent: Optional[ self.tree.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.setStyleSheet(f'QTreeView::item {{ padding: {settings.search_view_padding}px }}') self.model = SearchModel(q) + self._refreshing = False + self._refresh_pending = False self.tree.setModel(self.model) self.model.modelReset.connect(self.on_data_refresh) self.layout().addWidget(self.error_view) @@ -293,13 +336,28 @@ def restore_index(self, thread_id:str|None, fallback_row: int): self.tree.setCurrentIndex(index) def refresh(self) -> None: - """Refresh the search listing and restore the selection, if possible.""" + """Refresh the search listing and restore the selection, if possible. + + The underlying notmuch query runs asynchronously so the UI is never + blocked. A second call while a refresh is already in progress is + coalesced: the in-flight query finishes, then a fresh one starts + automatically so that the latest database state is picked up.""" + if self._refreshing: + self._refresh_pending = True + return + self._refreshing = True current_id, current_row = self.snapshot_index() - self.model.refresh() - self.restore_tree_geometry() - self.restore_index(current_id, current_row) - super().refresh() + def on_done() -> None: + self._refreshing = False + self.restore_tree_geometry() + self.restore_index(current_id, current_row) + super(SearchPanel, self).refresh() # sets dirty=False, emits has_refreshed + if self._refresh_pending: + self._refresh_pending = False + self.refresh() + + self.model.refresh_async(callback=on_done) def update_thread(self, thread_id: str, msg_id: str|None= None) -> None: logger.info("Search '%s': updating thread '%s'", self.q, thread_id)