Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions dodo/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]', ''))
Expand All @@ -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

Expand Down
91 changes: 79 additions & 12 deletions dodo/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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}'],
Expand All @@ -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"""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down