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 bc69842..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 @@ -75,7 +116,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 +125,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""" @@ -230,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) @@ -284,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)