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 : : }
|