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