MailJD nbsp;Β·nbsp; Test Dashboard nbsp;Β·nbsp; Coverage
LCOV - code coverage report
Current view: top level - app - SearchCoordinator.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 88.1 % 252 222
Test Date: 2026-07-27 17:53:44 Functions: 100.0 % 20 20
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 54.0 % 352 190

             Branch data     Line data    Source code
       1                 :             : #include "SearchCoordinator.h"
       2                 :             : 
       3                 :             : #include <QTimer>
       4                 :             : 
       5                 :             : #include "app/SearchQuery.h"
       6                 :             : #include "controller/MailController.h"
       7                 :             : #include "ui/CommandBar.h"
       8                 :             : #include "ui/FolderTree.h"
       9                 :             : #include "ui/MailFilterProxyModel.h"
      10                 :             : #include "ui/MailListModel.h"
      11                 :             : #include "ui/SearchPanel.h"
      12                 :             : 
      13                 :             : // Page size for the local FTS result list (additional pages via :search-more).
      14                 :             : static constexpr int kGlobalSearchPageSize = 200;
      15                 :             : 
      16                 :          58 : SearchCoordinator::SearchCoordinator(const Deps &deps, QObject *parent)
      17         [ +  - ]:          58 :     : QObject(parent), m_d(deps) {
      18                 :             :   // Re-selecting the virtual "Suche" node restores the search results view.
      19         [ +  - ]:          58 :   connect(m_d.folderTree, &FolderTree::searchNodeSelected, this, [this]() {
      20         [ #  # ]:           0 :     if (!m_searchNodeQuery.isEmpty())
      21                 :           0 :       runSearch(m_searchNodeQuery);
      22                 :           0 :   });
      23                 :             : 
      24                 :             :   // Closing the "Suche" node (context menu) cancels and dismisses the search.
      25                 :          58 :   connect(m_d.folderTree, &FolderTree::searchNodeCloseRequested, this,
      26         [ +  - ]:          58 :           &SearchCoordinator::exitSearch);
      27                 :             : 
      28                 :             :   // Enter in search: show the first local result page in the main mail list.
      29                 :          58 :   connect(m_d.commandBar, &CommandBar::searchSubmitted, this,
      30         [ +  - ]:          59 :           [this](const QString &query) { runSearch(query); });
      31                 :             : 
      32                 :             :   // Sprint 59 (U3): the SearchPanel runs through the SAME runSearch() path.
      33                 :             :   // It serializes its (freeText, filter) state into the canonical query, so
      34                 :             :   // panel and bar stay in sync β€” the filter state has a single home.
      35         [ +  - ]:          58 :   connect(m_d.searchPanel, &SearchPanel::searchRequested, this, [this]() {
      36                 :           1 :     m_searchDebounce->stop(); // explicit run cancels any pending debounce
      37   [ +  -  +  -  :           1 :     runSearch(buildSearchQuery(m_d.searchPanel->freeText(),
                   +  - ]
      38         [ +  - ]:           2 :                                m_d.searchPanel->filter()));
      39                 :           1 :   });
      40                 :             :   // "Reset" in the panel leaves search mode entirely.
      41                 :          58 :   connect(m_d.searchPanel, &SearchPanel::cleared, this,
      42         [ +  - ]:          58 :           &SearchCoordinator::exitSearch);
      43                 :             : 
      44                 :             :   // Sprint 60 (A1): live filtering. Each control change in the panel updates
      45                 :             :   // the search string in the CommandBar IMMEDIATELY (visible feedback) and
      46                 :             :   // arms a short debounce timer; when it fires, the search runs with the
      47                 :             :   // current state.
      48   [ +  -  +  -  :          58 :   m_searchDebounce = new QTimer(this);
             -  +  -  - ]
      49         [ +  - ]:          58 :   m_searchDebounce->setSingleShot(true);
      50         [ +  - ]:          58 :   m_searchDebounce->setInterval(300);
      51         [ +  - ]:          58 :   connect(m_d.searchPanel, &SearchPanel::filterEdited, this, [this]() {
      52         [ +  - ]:          64 :     const QString q = buildSearchQuery(m_d.searchPanel->freeText(),
      53   [ +  -  +  - ]:          96 :                                        m_d.searchPanel->filter());
      54         [ +  - ]:          32 :     m_d.commandBar->setInputText(q);
      55         [ +  - ]:          32 :     m_searchDebounce->start();
      56                 :          32 :   });
      57         [ +  - ]:          58 :   connect(m_searchDebounce, &QTimer::timeout, this, [this]() {
      58   [ +  -  +  -  :          12 :     runSearch(buildSearchQuery(m_d.searchPanel->freeText(),
                   +  - ]
      59         [ +  - ]:          24 :                                m_d.searchPanel->filter()));
      60                 :          12 :   });
      61                 :             : 
      62                 :             :   // Receive server-side search results from the dedicated search connection.
      63                 :          58 :   connect(m_d.controller, &MailController::serverSearchResultReceived, this,
      64         [ +  - ]:          58 :           [this](const QList<qint64> &uids, qint64 folderId,
      65                 :             :                  const QString &folderPath) {
      66         [ +  + ]:          22 :             if (m_lastSearchQuery.isEmpty())
      67                 :           6 :               return;
      68                 :             : 
      69                 :          16 :             int added = 0;
      70                 :          16 :             QList<MailHeader> extra;
      71         [ +  + ]:          53 :             for (qint64 uid : uids) {
      72                 :             :               // T-407: Use the folder context from the search signal
      73         [ +  - ]:          37 :               auto hdr = m_d.cache->header(folderId, uid);
      74         [ -  + ]:          37 :               if (!hdr)
      75                 :           0 :                 continue;
      76                 :             : 
      77                 :             :               // T-189: Skip if messageId already in results
      78         [ +  + ]:          37 :               if (!hdr->messageId.isEmpty()) {
      79         [ +  + ]:          35 :                 if (m_searchMessageIds.contains(hdr->messageId))
      80                 :          28 :                   continue;
      81         [ +  - ]:           7 :                 m_searchMessageIds.insert(hdr->messageId);
      82                 :             :               }
      83                 :             : 
      84                 :             :               // T-190/T-79.E5: folder name shown via display-only badge β€”
      85                 :             :               // mutating the subject leaked "[folder]" into replies (M16).
      86         [ +  - ]:           9 :               m_d.listModel->setFolderBadge(folderId, folderPath);
      87         [ +  - ]:           9 :               extra.append(*hdr);
      88                 :           9 :               ++added;
      89         [ +  + ]:          37 :             }
      90         [ +  + ]:          16 :             if (!extra.isEmpty()) {
      91         [ +  - ]:           9 :               m_d.listModel->appendHeaders(extra);
      92   [ +  -  +  - ]:           9 :               m_d.folderTree->updateSearchCount(m_d.listModel->rowCount());
      93                 :             :               // Update total count + show per-folder increment
      94         [ +  - ]:           9 :               emit statusMessage(
      95         [ +  - ]:           9 :                   QString::fromUtf8("Suche: %1 Treffer f\xc3\xbcr '%2'")
      96   [ +  -  +  - ]:          18 :                       .arg(m_d.listModel->rowCount())
      97         [ +  - ]:          18 :                       .arg(m_lastSearchQuery));
      98         [ +  - ]:           9 :               emit keyedStatusMessage(QStringLiteral("search"),
      99         [ +  - ]:           9 :                                       tr("\xf0\x9f\x94\x8d +%1 from %2")
     100         [ +  - ]:          18 :                                           .arg(added)
     101         [ +  - ]:          27 :                                           .arg(folderPath),
     102                 :             :                                       5000);
     103                 :             :             }
     104                 :          16 :           });
     105                 :             : 
     106                 :             :   // Server-side search complete (all folders searched).
     107                 :          58 :   connect(m_d.controller, &MailController::serverSearchComplete, this,
     108         [ +  - ]:          58 :           [this]() {
     109         [ +  - ]:           8 :             if (m_lastSearchQuery.isEmpty())
     110                 :           8 :               return;
     111         [ #  # ]:           0 :             emit keyedStatusMessage(
     112                 :           0 :                 QStringLiteral("search"),
     113         [ #  # ]:           0 :                 tr("πŸ” Server search complete (%1 results)")
     114   [ #  #  #  # ]:           0 :                     .arg(m_d.listModel->rowCount()),
     115                 :             :                 5000);
     116                 :             :           });
     117                 :          58 : }
     118                 :             : 
     119                 :          37 : QList<MailHeader> SearchCoordinator::headersForSearchResults(
     120                 :             :     const QList<MailCache::SearchResult> &results) {
     121                 :          37 :   QList<MailHeader> headers;
     122         [ +  + ]:          98 :   for (const auto &r : results) {
     123         [ +  - ]:          61 :     auto hdr = m_d.cache->header(r.folderId, r.uid);
     124         [ -  + ]:          61 :     if (!hdr)
     125                 :           0 :       continue;
     126                 :             : 
     127                 :             :     // T-189: Skip if messageId already seen
     128         [ +  + ]:          61 :     if (!hdr->messageId.isEmpty()) {
     129         [ +  + ]:          36 :       if (m_searchMessageIds.contains(hdr->messageId))
     130                 :           1 :         continue;
     131         [ +  - ]:          35 :       m_searchMessageIds.insert(hdr->messageId);
     132                 :             :     }
     133                 :             : 
     134                 :             :     // T-190/T-79.E5: folder name shown via display-only badge β€” mutating
     135                 :             :     // the subject leaked "[folder]" into replies (M16).
     136         [ +  - ]:          60 :     m_d.listModel->setFolderBadge(r.folderId, r.folderPath);
     137         [ +  - ]:          60 :     headers.append(*hdr);
     138         [ +  + ]:          61 :   }
     139                 :          37 :   return headers;
     140                 :           0 : }
     141                 :             : 
     142                 :          51 : void SearchCoordinator::resetSearchPanel() {
     143                 :          51 :   m_d.searchPanel->hide();
     144   [ +  -  -  -  :          51 :   m_d.searchPanel->setState(QString(), MailCache::SearchFilter{});
             -  -  -  - ]
     145                 :          51 : }
     146                 :             : 
     147                 :          20 : void SearchCoordinator::exitSearch() {
     148                 :             :   // Cancel any in-flight server search so it stops scanning folders.
     149                 :          20 :   m_d.controller->cancelServerSearch();
     150                 :             :   // Sprint 60 (A1): cancel any pending live-search debounce.
     151                 :          20 :   m_searchDebounce->stop();
     152                 :          20 :   m_lastSearchQuery.clear();
     153                 :          20 :   clearLocalSearchPagination();
     154                 :          20 :   m_searchMessageIds.clear();
     155                 :          20 :   m_searchNodeQuery.clear();
     156                 :          20 :   m_d.listModel->clearFolderBadges(); // T-79.E5: badges are search-only
     157         [ +  - ]:          20 :   m_d.proxy->setFilterText({});
     158         [ +  - ]:          20 :   emit windowTitleChangeRequested(QStringLiteral("MailJD"));
     159         [ +  - ]:          20 :   emit statusCleared(QStringLiteral("search"));
     160                 :          20 :   m_d.folderTree->hideSearchNode();
     161                 :             :   // Sprint 59 (U2): hide and reset the refinement panel.
     162                 :          20 :   resetSearchPanel();
     163         [ +  + ]:          20 :   if (!m_preSearchFolder.isEmpty()) {
     164                 :           2 :     m_d.folderTree->selectFolder(m_preSearchFolder);
     165                 :             :     // Force controller reload β€” tree may not re-emit folderSelected if the
     166                 :             :     // same folder was already selected.
     167                 :           2 :     m_d.controller->onFolderSelected(m_preSearchFolder);
     168                 :           2 :     m_preSearchFolder.clear();
     169                 :             :   }
     170                 :          20 :   emit mailListFocusRequested();
     171                 :          20 : }
     172                 :             : 
     173                 :          30 : void SearchCoordinator::onRealFolderSelected() {
     174         [ +  + ]:          30 :   if (!m_lastSearchQuery.isEmpty()) {
     175                 :           1 :     m_lastSearchQuery.clear();
     176                 :           1 :     clearLocalSearchPagination();
     177                 :           1 :     m_d.listModel->clearFolderBadges(); // T-79.E5: badges are search-only
     178         [ +  - ]:           1 :     emit windowTitleChangeRequested(QStringLiteral("MailJD"));
     179                 :             :   }
     180                 :             :   // Sprint 60 (A2): the SearchPanel belongs to search mode only β€” hide and
     181                 :             :   // reset it whenever a real folder is selected, and cancel any pending
     182                 :             :   // live-search debounce so it cannot fire after we left.
     183                 :          30 :   m_searchDebounce->stop();
     184                 :          30 :   resetSearchPanel();
     185                 :          30 : }
     186                 :             : 
     187                 :           3 : void SearchCoordinator::onSearchBarEscape() {
     188         [ +  + ]:           3 :   if (m_lastSearchQuery.isEmpty())
     189                 :           2 :     return;
     190                 :           1 :   m_lastSearchQuery.clear();
     191                 :           1 :   clearLocalSearchPagination();
     192                 :           1 :   m_searchMessageIds.clear();
     193         [ +  - ]:           1 :   emit windowTitleChangeRequested(QStringLiteral("MailJD"));
     194         [ +  - ]:           1 :   emit statusCleared(QStringLiteral("search"));
     195                 :           1 :   m_searchNodeQuery.clear();
     196                 :           1 :   m_d.folderTree->hideSearchNode();
     197                 :           1 :   m_searchDebounce->stop();
     198                 :           1 :   resetSearchPanel();
     199         [ -  + ]:           1 :   if (!m_preSearchFolder.isEmpty()) {
     200                 :           0 :     m_d.folderTree->selectFolder(m_preSearchFolder);
     201                 :             :     // Force controller reload β€” tree may not re-emit folderSelected
     202                 :             :     // if the same folder was already selected
     203                 :           0 :     m_d.controller->onFolderSelected(m_preSearchFolder);
     204                 :           0 :     m_preSearchFolder.clear();
     205                 :             :   }
     206                 :             : }
     207                 :             : 
     208                 :           4 : void SearchCoordinator::onCommandBarCancelled() {
     209         [ +  + ]:           4 :   if (m_lastSearchQuery.isEmpty())
     210                 :           3 :     return;
     211                 :           1 :   m_lastSearchQuery.clear();
     212                 :           1 :   clearLocalSearchPagination();
     213                 :           1 :   m_searchMessageIds.clear();
     214                 :             :   // Cancel any ongoing server search
     215                 :           1 :   m_d.controller->cancelServerSearch();
     216         [ +  - ]:           1 :   emit statusCleared(QStringLiteral("search"));
     217         [ +  - ]:           1 :   emit windowTitleChangeRequested(QStringLiteral("MailJD"));
     218         [ -  + ]:           1 :   if (!m_preSearchFolder.isEmpty()) {
     219                 :           0 :     m_d.folderTree->selectFolder(m_preSearchFolder);
     220                 :           0 :     m_preSearchFolder.clear();
     221                 :             :   }
     222                 :             : }
     223                 :             : 
     224                 :          40 : void SearchCoordinator::runSearch(const QString &query) {
     225                 :             :   // T-192: Parse filter prefixes from the query.
     226                 :          40 :   MailCache::SearchFilter filter;
     227         [ +  - ]:          40 :   QString ftsQuery = parseSearchQuery(query, filter);
     228                 :             : 
     229                 :             :   // Sprint 59 (A1): a search is valid when there is free text OR any facet.
     230                 :             :   // A bare-empty input (no text, no facets) is not a search.
     231   [ +  +  +  -  :          40 :   if (ftsQuery.isEmpty() && filter.isEmpty())
             +  +  +  + ]
     232                 :           3 :     return;
     233                 :             : 
     234                 :             :   // Sprint 59: the canonical query is the single source of truth for the
     235                 :             :   // window title, the "Suche" node and the search bar. For a pure facets-only
     236                 :             :   // search it contains only prefixes (empty free text).
     237         [ +  - ]:          37 :   const QString canonicalQuery = buildSearchQuery(ftsQuery, filter);
     238                 :             : 
     239                 :             :   // T-188: Save current folder for Esc restore β€” but only when ENTERING
     240                 :             :   // search, not when refining an already-active search (otherwise we'd lose
     241                 :             :   // the folder to return to).
     242                 :          37 :   const bool alreadySearching = !m_lastSearchQuery.isEmpty();
     243         [ +  + ]:          37 :   if (!alreadySearching)
     244                 :          22 :     m_preSearchFolder = m_d.controller->currentFolder();
     245                 :             : 
     246                 :             :   // T-189: Dedup set + T-190: folder prefix
     247                 :          37 :   m_searchMessageIds.clear();
     248                 :          37 :   m_lastLocalSearchQuery = ftsQuery;
     249                 :          37 :   m_lastSearchFilter = filter;
     250                 :          37 :   m_localSearchOffset = 0;
     251                 :          37 :   m_localSearchHasMore = false;
     252                 :             :   // Keep the canonical query so re-selecting the "Suche" node restores the
     253                 :             :   // exact same filter state.
     254                 :          37 :   m_searchNodeQuery = canonicalQuery;
     255                 :             : 
     256                 :             :   // Sprint 59 (U2/U3): push the parsed state into the panel and the bar so
     257                 :             :   // all three (state, panel, bar) agree. Signals are blocked by setState()
     258                 :             :   // and setInputText() respectively, so this never re-triggers a search.
     259         [ +  - ]:          37 :   m_d.searchPanel->setKnownFolders(m_knownFolders);
     260         [ +  - ]:          37 :   if (m_d.cache)
     261   [ +  -  +  - ]:          37 :     m_d.searchPanel->setKnownTags(m_d.cache->knownLabels());
     262         [ +  - ]:          37 :   m_d.searchPanel->setState(ftsQuery, filter);
     263         [ +  - ]:          37 :   m_d.searchPanel->show();
     264         [ +  - ]:          37 :   m_d.commandBar->setInputText(canonicalQuery);
     265                 :             : 
     266                 :             :   // 67.A3: a re-run (live-filter debounce, refinement) rebuilds the
     267                 :             :   // result list via setHeaders β†’ model reset β†’ the clicked row would
     268                 :             :   // silently lose its highlight. Snapshot the selection to restore it
     269                 :             :   // afterwards. Entering search fresh intentionally starts unselected.
     270                 :          37 :   QPair<qint64, qint64> previousSelection{-1, -1};
     271   [ +  +  +  -  :          37 :   if (alreadySearching && m_d.currentSelection)
                   +  + ]
     272         [ +  - ]:          15 :     previousSelection = m_d.currentSelection();
     273                 :             : 
     274                 :             :   // Populate the main list
     275                 :             :   // T-197: Clear selection first to prevent currentChanged from
     276                 :             :   // firing onMailSelected for the auto-selected first row
     277         [ +  - ]:          37 :   emit mailSelectionClearRequested();
     278         [ +  - ]:          37 :   appendLocalSearchPage(canonicalQuery, true);
     279                 :             : 
     280                 :             :   // 67.A3: restore the selection when the mail is still in the results.
     281   [ +  +  +  + ]:          38 :   if (previousSelection.second > 0 &&
     282   [ +  -  +  - ]:           1 :       m_d.listModel->rowForUid(previousSelection.second,
     283                 :             :                                previousSelection.first) >= 0) {
     284         [ +  - ]:           1 :     emit mailRevealRequested(previousSelection.first,
     285                 :             :                              previousSelection.second);
     286                 :             :   }
     287                 :             : 
     288                 :             :   // Also trigger server-side IMAP SEARCH. Sprint 59 (S1): the full filter is
     289                 :             :   // passed through; the controller maps the server-friendly facets to a
     290                 :             :   // composite IMAP SEARCH and skips the scan when nothing is server-mappable
     291                 :             :   // (e.g. a has:attachment-only search stays local).
     292                 :          37 :   m_lastSearchQuery = canonicalQuery;
     293         [ +  - ]:          37 :   emit keyedStatusMessage(
     294                 :          74 :       QStringLiteral("search"),
     295         [ +  - ]:          74 :       QString::fromUtf8("\xf0\x9f\x94\x8d Serversuche l\xc3\xa4uft\xe2\x80\xa6"),
     296                 :             :       0);
     297         [ +  - ]:          37 :   m_d.controller->serverSearch(ftsQuery, filter);
     298   [ +  +  +  + ]:          43 : }
     299                 :             : 
     300                 :          37 : int SearchCoordinator::appendLocalSearchPage(const QString &displayQuery,
     301                 :             :                                              bool replace) {
     302                 :             :   // Sprint 59 (A1): there is an active search when there is free text OR a
     303                 :             :   // facet. Previously this only checked the free text, so a facets-only
     304                 :             :   // search ("all unread mails with an attachment") looked like "no search".
     305   [ +  +  +  -  :          37 :   if (m_lastLocalSearchQuery.isEmpty() && m_lastSearchFilter.isEmpty()) {
             -  +  -  + ]
     306         [ #  # ]:           0 :     if (replace) {
     307         [ #  # ]:           0 :       m_d.listModel->setHeaders({});
     308         [ #  # ]:           0 :       m_d.folderTree->showSearchNode(0);
     309                 :             :     }
     310                 :           0 :     m_localSearchHasMore = false;
     311                 :           0 :     return 0;
     312                 :             :   }
     313                 :             : 
     314                 :             :   auto results =
     315                 :          37 :       m_d.cache->searchFts(m_lastLocalSearchQuery, m_lastSearchFilter,
     316         [ +  - ]:          37 :                            kGlobalSearchPageSize + 1, m_localSearchOffset);
     317                 :          37 :   m_localSearchHasMore = results.size() > kGlobalSearchPageSize;
     318         [ -  + ]:          37 :   if (m_localSearchHasMore)
     319                 :           0 :     results.removeLast();
     320                 :          37 :   m_localSearchOffset += results.size();
     321                 :             : 
     322         [ +  - ]:          37 :   const auto headers = headersForSearchResults(results);
     323         [ +  - ]:          37 :   if (replace)
     324         [ +  - ]:          37 :     m_d.listModel->setHeaders(headers);
     325         [ #  # ]:           0 :   else if (!headers.isEmpty())
     326         [ #  # ]:           0 :     m_d.listModel->appendHeaders(headers);
     327                 :             : 
     328         [ +  - ]:          37 :   const int visibleCount = m_d.listModel->rowCount();
     329         [ +  - ]:          37 :   m_d.folderTree->showSearchNode(visibleCount);
     330         [ +  - ]:          37 :   emit windowTitleChangeRequested(
     331         [ +  - ]:          37 :       QString::fromUtf8("Suchergebnisse f\xc3\xbcr '%1' - MailJD")
     332         [ +  - ]:          74 :           .arg(displayQuery));
     333         [ -  + ]:          37 :   if (m_localSearchHasMore) {
     334         [ #  # ]:           0 :     emit statusMessage(
     335         [ #  # ]:           0 :         QString::fromUtf8(
     336                 :             :             "Suche: %1 Treffer f\xc3\xbcr '%2' (weitere mit :search-more)")
     337         [ #  # ]:           0 :             .arg(visibleCount)
     338         [ #  # ]:           0 :             .arg(displayQuery));
     339                 :             :   } else {
     340   [ +  -  +  - ]:          74 :     emit statusMessage(QString::fromUtf8("Suche: %1 Treffer f\xc3\xbcr '%2'")
     341         [ +  - ]:          74 :                            .arg(visibleCount)
     342         [ +  - ]:         111 :                            .arg(displayQuery));
     343                 :             :   }
     344                 :          37 :   return headers.size();
     345                 :          37 : }
     346                 :             : 
     347                 :           3 : void SearchCoordinator::loadMoreLocalResults() {
     348   [ +  +  -  +  :           3 :   if (m_lastSearchQuery.isEmpty() || m_lastLocalSearchQuery.isEmpty()) {
                   +  + ]
     349         [ +  - ]:           2 :     emit statusMessage(QStringLiteral("Keine lokale Suche aktiv"));
     350                 :           2 :     return;
     351                 :             :   }
     352         [ +  - ]:           1 :   if (!m_localSearchHasMore) {
     353         [ +  - ]:           1 :     emit statusMessage(QStringLiteral("Keine weiteren lokalen Suchtreffer"));
     354                 :           1 :     return;
     355                 :             :   }
     356                 :           0 :   appendLocalSearchPage(m_lastSearchQuery, false);
     357                 :             : }
     358                 :             : 
     359                 :          23 : void SearchCoordinator::clearLocalSearchPagination() {
     360                 :          23 :   m_lastLocalSearchQuery.clear();
     361                 :          23 :   m_lastSearchFilter = {};
     362                 :          23 :   m_localSearchOffset = 0;
     363                 :          23 :   m_localSearchHasMore = false;
     364                 :          23 : }
     365                 :             : 
     366                 :          14 : void SearchCoordinator::updateQuickResults(const QString &query) {
     367         [ +  - ]:          14 :   m_quickResults.clear();
     368   [ +  -  +  + ]:          14 :   if (query.trimmed().isEmpty()) {
     369         [ +  - ]:           1 :     m_d.commandBar->setSearchResults({});
     370                 :           1 :     return;
     371                 :             :   }
     372                 :             :   // T-192: Parse filter prefixes
     373                 :          13 :   MailCache::SearchFilter filter;
     374         [ +  - ]:          13 :   QString ftsQuery = parseSearchQuery(query, filter);
     375         [ -  + ]:          13 :   if (ftsQuery.isEmpty()) {
     376         [ #  # ]:           0 :     m_d.commandBar->setSearchResults({});
     377                 :           0 :     return;
     378                 :             :   }
     379                 :             :   // Local FTS5 search with filters
     380         [ +  - ]:          13 :   auto results = m_d.cache->searchFts(ftsQuery, filter, 20);
     381                 :          13 :   QStringList display;
     382   [ +  -  +  -  :          40 :   for (const auto &r : results) {
                   +  + ]
     383         [ +  - ]:          81 :     display.append(QStringLiteral("[%1] %2 β€” %3")
     384   [ +  -  +  -  :          54 :                        .arg(r.folderPath, r.subject.left(60), r.from.left(40)));
                   +  - ]
     385         [ +  - ]:          27 :     m_quickResults.append(r);
     386                 :             :   }
     387         [ +  - ]:          13 :   m_d.commandBar->setSearchResults(display);
     388         [ +  + ]:          13 :   if (results.isEmpty()) {
     389         [ +  - ]:           2 :     emit statusMessage(
     390         [ +  - ]:           6 :         QStringLiteral("Keine Treffer fΓΌr '%1'").arg(ftsQuery));
     391                 :             :   } else {
     392   [ +  -  +  - ]:          22 :     emit statusMessage(QStringLiteral("%1 Treffer").arg(results.size()));
     393                 :             :   }
     394   [ +  -  +  - ]:          13 : }
     395                 :             : 
     396                 :           3 : MailCache::SearchResult SearchCoordinator::quickResultAt(int index) const {
     397   [ +  +  +  +  :           3 :   if (index < 0 || index >= m_quickResults.size())
                   +  + ]
     398                 :           2 :     return {};
     399                 :           1 :   return m_quickResults.at(index);
     400                 :             : }
        

Generated by: LCOV version 2.0-1