From 2bf8d4a3c2d99fe7bb4311e72b16878ba1b9caba Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 8 Oct 2026 11:02:52 +0200 Subject: [PATCH] Perf: async + cached project indexing (fixes Alt+G open stall) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ProjectIndexer (project_lib, QtConcurrent + QFutureWatcher, 6 tests): runs listFiles/listSymbols on the thread pool, delivers results on the UI thread via filesReady/symbolsReady, caches per project root, and drops stale results via a per-root generation counter. Switchers now open the palette immediately — cache hit is instant, miss shows a transient 'Indexing…' placeholder — and repopulate when the background job finishes. The ~0.5-1s ctags stall on Alt+G no longer blocks the UI thread. Cache invalidated on KateProjectBridge:: projectChanged. Register QList metatype; link Qt6::Concurrent. Item building factored into buildFileItems/buildSymbolItems (shared by the cache and async paths). --- CMakeLists.txt | 2 +- docs/PERF.md | 20 +++- docs/PLAN.md | 5 + src/plugin/ollieplugin.cpp | 135 +++++++++++++++------- src/plugin/ollieplugin.h | 8 ++ src/project/CMakeLists.txt | 7 ++ src/project/projectindexer.cpp | 166 ++++++++++++++++++++++++++++ src/project/projectindexer.h | 92 +++++++++++++++ src/project/symbolindex.h | 8 ++ src/project/test_projectindexer.cpp | 127 +++++++++++++++++++++ 10 files changed, 523 insertions(+), 47 deletions(-) create mode 100644 src/project/projectindexer.cpp create mode 100644 src/project/projectindexer.h create mode 100644 src/project/test_projectindexer.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 79eb2b6..fc5aaca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,7 +18,7 @@ include(KDECompilerSettings NO_POLICY_SCOPE) set(CMAKE_POSITION_INDEPENDENT_CODE ON) -find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS Core Gui Widgets) +find_package(Qt6 ${QT_MIN_VERSION} REQUIRED COMPONENTS Core Gui Widgets Concurrent) find_package(Qt6 ${QT_MIN_VERSION} COMPONENTS Test) find_package(KF6 ${KF_MIN_VERSION} COMPONENTS TextEditor XmlGui CoreAddons I18n) diff --git a/docs/PERF.md b/docs/PERF.md index 5b84238..73856fa 100644 --- a/docs/PERF.md +++ b/docs/PERF.md @@ -63,9 +63,19 @@ The shipped `olliepalette.so` is a **Debug** build (`-g`, no `-O`). Release is ~5-8× faster on every hot path above. Install a `Release` or `RelWithDebInfo` build. -## Open follow-up — move indexing off the UI thread +## Follow-up — move indexing off the UI thread (done) -`listFiles` (~85 ms) and especially `listSymbols` (~0.5-1 s) run synchronously -when the switcher opens. For large projects they should run on a worker thread -(or be cached + refreshed on `KateProjectBridge::projectChanged`) so the palette -appears instantly and fills in as results arrive. Not yet done. +`ProjectIndexer` (`src/project/projectindexer.{h,cpp}`, in `project_lib`, 6 +tests) runs `listFiles` / `listSymbols` on the thread pool (`QtConcurrent` + +`QFutureWatcher`) and delivers results on the UI thread via `filesReady` / +`symbolsReady`, caching per project root. The switchers now: + +- open the palette **immediately** (cache hit → instant; miss → a transient + "Indexing…" / "Indexing symbols…" placeholder), and +- repopulate via the ready signal when the background job finishes. + +A per-root generation counter drops superseded/invalidated results. The cache +is dropped on `KateProjectBridge::projectChanged` so a project switch re-indexes. +Net effect: the UI thread never blocks on git/ctags; the ~1 s Alt+G stall is +gone (first open shows a placeholder and fills in; subsequent opens are instant +from cache). diff --git a/docs/PLAN.md b/docs/PLAN.md index b3a19b7..8dab9fa 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -250,6 +250,11 @@ Therefore the palette is replaced, not extended, and the replacement is built on reachable from the M-x palette (`ollie:switch:file`) and, by objectName `ollie_goto_file`, from a radial. (2 path-ranking tests in `test_palettemodel`.) + - PERFORMANCE: file/symbol indexing runs **off the UI thread** via + `ProjectIndexer` (QtConcurrent, cached per root, invalidated on + `projectChanged`); the palette opens instantly and fills in. The palette + re-rank is **incremental** on type-forward (see `PaletteModel`). See + `docs/PERF.md` for measured numbers. - Fuzzy symbol switcher scoped to the folder: DONE. `src/project/symbolindex.{h,cpp}` (in `project_lib`, 7 unit tests incl. a live ctags run): diff --git a/src/plugin/ollieplugin.cpp b/src/plugin/ollieplugin.cpp index e0687bb..76d358e 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/plugin/ollieplugin.cpp @@ -266,6 +266,10 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow) m_projectBridge.setProjectView(nullptr); } }); + // When Kate's project changes, drop the cached index so the next open + // re-indexes the new project rather than serving stale results. + connect(&m_projectBridge, &KateProjectBridge::projectChanged, this, + [this]() { m_indexer.invalidateAll(); }); } setupRadials(); @@ -534,6 +538,28 @@ void OllieView::runAction(const QString &actionId) } } +QList OllieView::buildFileItems(const QString &root, + const QStringList &files) const +{ + const qint64 now = QDateTime::currentSecsSinceEpoch(); + QList items; + items.reserve(files.size()); + for (const QString &rel : files) { + PaletteItem item; + item.id = kFilePrefix + rel; + // Match and display the full project-relative path: FuzzyRanker scores + // against the label (orderless tokens, so "src main" matches + // "src/main.cpp"), and the delegate highlights matched characters across + // the whole path. The base name still ranks well because an exact or + // substring hit on it outscores a scattered subsequence. + item.label = rel; + item.frecency = m_frecency.bonus( + kFilePrefix + root + QLatin1Char('\t') + rel, now); + items.push_back(item); + } + return items; +} + void OllieView::showFileSwitcher() { if (!m_filePalette) { @@ -549,28 +575,30 @@ void OllieView::showFileSwitcher() openProjectFile(rel); } }); + // Results that arrive asynchronously repopulate the palette if it is + // still showing the same project. Connected once. + connect(&m_indexer, &ProjectIndexer::filesReady, this, + [this](const QString &root, const QStringList &files) { + if (root == m_fileSwitcherRoot && m_filePalette + && m_filePalette->isVisible()) { + m_filePalette->setItems(buildFileItems(root, files)); + } + }); } m_fileSwitcherRoot = currentProjectRoot(); - const QStringList files = ProjectIndex::listFiles(m_fileSwitcherRoot); - const qint64 now = QDateTime::currentSecsSinceEpoch(); - QList items; - items.reserve(files.size()); - for (const QString &rel : files) { - PaletteItem item; - item.id = kFilePrefix + rel; - // Match and display the full project-relative path: FuzzyRanker scores - // against the label (orderless tokens, so "src main" matches - // "src/main.cpp"), and the delegate highlights matched characters across - // the whole path. The base name still ranks well because an exact or - // substring hit on it outscores a scattered subsequence. - item.label = rel; - item.frecency = m_frecency.bonus( - kFilePrefix + m_fileSwitcherRoot + QLatin1Char('\t') + rel, now); - items.push_back(item); + // Show immediately so there is no stall; fill from cache if we have it, + // otherwise show a transient placeholder and let the async result populate. + if (m_indexer.hasFiles(m_fileSwitcherRoot)) { + m_filePalette->setItems( + buildFileItems(m_fileSwitcherRoot, m_indexer.cachedFiles(m_fileSwitcherRoot))); + } else { + PaletteItem loading; + loading.id = QStringLiteral("ollie:loading"); + loading.label = i18n("Indexing project…"); + m_filePalette->setItems({loading}); } - m_filePalette->setItems(items); if (QWidget *w = m_mainWindow->window()) { const QSize sz = m_filePalette->size(); @@ -578,8 +606,10 @@ void OllieView::showFileSwitcher() w->height() / 6)); m_filePalette->move(topLeft); } - m_filePalette->activate(); + + // Kick off (or serve from cache) the index; filesReady repopulates. + m_indexer.requestFiles(m_fileSwitcherRoot); } void OllieView::openProjectFile(const QString &relativePath) @@ -594,33 +624,16 @@ void OllieView::openProjectFile(const QString &relativePath) } } -void OllieView::showSymbolSwitcher() +QList OllieView::buildSymbolItems(const QString &root, + const QList &symbols) const { - if (!m_symbolPalette) { - m_symbolPalette = new PaletteWidget(m_mainWindow->window()); - connect(m_symbolPalette, &PaletteWidget::activatedId, this, - [this](const QString &id) { - if (id.startsWith(kSymbolPrefix)) { - const QString loc = id.mid(QString(kSymbolPrefix).size()); - // Project-qualified key, as for the file switcher. - recordUsage(kSymbolPrefix + m_symbolSwitcherRoot - + QLatin1Char('\t') + loc); - goToSymbol(loc); - } - }); - } - - m_symbolSwitcherRoot = currentProjectRoot(); - const QStringList files = ProjectIndex::listFiles(m_symbolSwitcherRoot); - const QList symbols = SymbolIndex::listSymbols(m_symbolSwitcherRoot, files); const qint64 now = QDateTime::currentSecsSinceEpoch(); - QList items; items.reserve(symbols.size()); for (const Symbol &s : symbols) { PaletteItem item; // The id encodes the jump target as "relpath\tline"; the location is - // resolved against m_symbolSwitcherRoot on activation. + // resolved against the switcher root on activation. const QString loc = s.file + QLatin1Char('\t') + QString::number(s.line); item.id = kSymbolPrefix + loc; @@ -642,11 +655,50 @@ void OllieView::showSymbolSwitcher() } item.label = label; item.frecency = m_frecency.bonus( - kSymbolPrefix + m_symbolSwitcherRoot + QLatin1Char('\t') + loc, now); + kSymbolPrefix + root + QLatin1Char('\t') + loc, now); items.push_back(item); } - m_symbolPalette->setItems(items); + return items; +} + +void OllieView::showSymbolSwitcher() +{ + if (!m_symbolPalette) { + m_symbolPalette = new PaletteWidget(m_mainWindow->window()); + connect(m_symbolPalette, &PaletteWidget::activatedId, this, + [this](const QString &id) { + if (id.startsWith(kSymbolPrefix)) { + const QString loc = id.mid(QString(kSymbolPrefix).size()); + // Project-qualified key, as for the file switcher. + recordUsage(kSymbolPrefix + m_symbolSwitcherRoot + + QLatin1Char('\t') + loc); + goToSymbol(loc); + } + }); + connect(&m_indexer, &ProjectIndexer::symbolsReady, this, + [this](const QString &root, const QList &symbols) { + if (root == m_symbolSwitcherRoot && m_symbolPalette + && m_symbolPalette->isVisible()) { + m_symbolPalette->setItems(buildSymbolItems(root, symbols)); + } + }); + } + + m_symbolSwitcherRoot = currentProjectRoot(); + + // Open instantly; symbol indexing (ctags) is the slowest stage, so a + // placeholder is important here. The async result repopulates. + if (m_indexer.hasSymbols(m_symbolSwitcherRoot)) { + m_symbolPalette->setItems( + buildSymbolItems(m_symbolSwitcherRoot, + m_indexer.cachedSymbols(m_symbolSwitcherRoot))); + } else { + PaletteItem loading; + loading.id = QStringLiteral("ollie:loading"); + loading.label = i18n("Indexing symbols…"); + m_symbolPalette->setItems({loading}); + } if (QWidget *w = m_mainWindow->window()) { const QSize sz = m_symbolPalette->size(); @@ -654,8 +706,9 @@ void OllieView::showSymbolSwitcher() w->height() / 6)); m_symbolPalette->move(topLeft); } - m_symbolPalette->activate(); + + m_indexer.requestSymbols(m_symbolSwitcherRoot); } void OllieView::goToSymbol(const QString &locationId) diff --git a/src/plugin/ollieplugin.h b/src/plugin/ollieplugin.h index 8cf2e66..e7258c8 100644 --- a/src/plugin/ollieplugin.h +++ b/src/plugin/ollieplugin.h @@ -15,6 +15,8 @@ #include "frecencystore.h" #include "kateprojectbridge.h" +#include "projectindexer.h" +#include "palettemodel.h" // PaletteItem #include #include @@ -78,6 +80,11 @@ private: QString currentProjectRoot() const; // Directory of the active document, or empty when none/untitled. QString activeDocumentDir() const; + + // Build palette items from raw index results (shared by the cache-hit and + // async-arrival paths). Root is used to project-qualify the frecency key. + QList buildFileItems(const QString &root, const QStringList &files) const; + QList buildSymbolItems(const QString &root, const QList &symbols) const; // One harvested, runnable action with the provenance needed to build a // unique id and to disambiguate labels shared by several commands. struct ActionEntry { @@ -135,6 +142,7 @@ private: QHash> m_actionsById; FrecencyStore m_frecency; // persisted usage feeding the palette ranking KateProjectBridge m_projectBridge; // read-only view of Kate's project plugin + ProjectIndexer m_indexer; // async, cached file/symbol indexing }; } // namespace katecustom diff --git a/src/project/CMakeLists.txt b/src/project/CMakeLists.txt index 3566559..9c3f637 100644 --- a/src/project/CMakeLists.txt +++ b/src/project/CMakeLists.txt @@ -5,9 +5,12 @@ add_library(project_lib STATIC symbolindex.h kateprojectbridge.cpp kateprojectbridge.h + projectindexer.cpp + projectindexer.h ) target_link_libraries(project_lib PUBLIC Qt6::Core + Qt6::Concurrent ) target_include_directories(project_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) @@ -23,6 +26,10 @@ if(Qt6Test_FOUND) add_executable(test_kateprojectbridge test_kateprojectbridge.cpp) target_link_libraries(test_kateprojectbridge PRIVATE project_lib Qt6::Core Qt6::Test) add_test(NAME kateprojectbridge COMMAND test_kateprojectbridge) + + add_executable(test_projectindexer test_projectindexer.cpp) + target_link_libraries(test_projectindexer PRIVATE project_lib Qt6::Core Qt6::Test) + add_test(NAME projectindexer COMMAND test_projectindexer) endif() # Manual benchmark (not a ctest): times the Go-to-File / Go-to-Symbol pipeline diff --git a/src/project/projectindexer.cpp b/src/project/projectindexer.cpp new file mode 100644 index 0000000..0354209 --- /dev/null +++ b/src/project/projectindexer.cpp @@ -0,0 +1,166 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "projectindexer.h" + +#include "projectindex.h" + +#include +#include +#include + +namespace katecustom +{ + +ProjectIndexer::ProjectIndexer(QObject *parent) + : QObject(parent) +{ + // Needed so QList can travel through QVariant / queued signals and + // be captured by QSignalSpy in tests. + qRegisterMetaType("katecustom::Symbol"); + qRegisterMetaType>("QList"); +} + +ProjectIndexer::~ProjectIndexer() +{ + // Deleting a running watcher disconnects it; the pooled task completes and + // its result is discarded. We do not block on it. + delete m_filesWatcher; + delete m_symbolsWatcher; +} + +bool ProjectIndexer::hasFiles(const QString &root) const +{ + auto it = m_cache.constFind(root); + return it != m_cache.constEnd() && it->filesValid; +} + +bool ProjectIndexer::hasSymbols(const QString &root) const +{ + auto it = m_cache.constFind(root); + return it != m_cache.constEnd() && it->symbolsValid; +} + +QStringList ProjectIndexer::cachedFiles(const QString &root) const +{ + return m_cache.value(root).files; +} + +QList ProjectIndexer::cachedSymbols(const QString &root) const +{ + return m_cache.value(root).symbols; +} + +void ProjectIndexer::invalidate(const QString &root) +{ + auto it = m_cache.find(root); + if (it == m_cache.end()) { + return; + } + it->filesValid = false; + it->symbolsValid = false; + it->files.clear(); + it->symbols.clear(); + ++it->generation; // drop any in-flight job for this root +} + +void ProjectIndexer::invalidateAll() +{ + for (auto it = m_cache.begin(); it != m_cache.end(); ++it) { + it->filesValid = false; + it->symbolsValid = false; + it->files.clear(); + it->symbols.clear(); + ++it->generation; + } +} + +void ProjectIndexer::requestFiles(const QString &root) +{ + if (root.isEmpty()) { + Q_EMIT filesReady(root, QStringList()); + return; + } + if (hasFiles(root)) { + Q_EMIT filesReady(root, m_cache.value(root).files); + return; + } + + // Supersede any previous files job. + if (m_filesWatcher) { + delete m_filesWatcher; + m_filesWatcher = nullptr; + } + m_filesJobRoot = root; + m_filesJobGen = m_cache[root].generation; // current generation for staleness + + m_filesWatcher = new QFutureWatcher(this); + connect(m_filesWatcher, &QFutureWatcherBase::finished, this, [this]() { + const QString root = m_filesJobRoot; + const QStringList files = m_filesWatcher->result(); + m_filesWatcher->deleteLater(); + m_filesWatcher = nullptr; + + // Drop the result if the root was invalidated since the job launched. + if (m_cache[root].generation != m_filesJobGen) { + return; + } + Entry &e = m_cache[root]; + e.files = files; + e.filesValid = true; + Q_EMIT filesReady(root, files); + }); + m_filesWatcher->setFuture(QtConcurrent::run([root]() { + return ProjectIndex::listFiles(root); + })); +} + +void ProjectIndexer::requestSymbols(const QString &root) +{ + if (root.isEmpty()) { + Q_EMIT symbolsReady(root, QList()); + return; + } + if (hasSymbols(root)) { + Q_EMIT symbolsReady(root, m_cache.value(root).symbols); + return; + } + + if (m_symbolsWatcher) { + delete m_symbolsWatcher; + m_symbolsWatcher = nullptr; + } + m_symbolsJobRoot = root; + m_symbolsJobGen = m_cache[root].generation; + + // Reuse cached files if present, else list them inside the worker. + const bool haveFiles = hasFiles(root); + const QStringList knownFiles = haveFiles ? m_cache.value(root).files : QStringList(); + + using Result = QPair>; + m_symbolsWatcher = new QFutureWatcher(this); + connect(m_symbolsWatcher, &QFutureWatcherBase::finished, this, [this]() { + const QString root = m_symbolsJobRoot; + const Result r = m_symbolsWatcher->result(); + m_symbolsWatcher->deleteLater(); + m_symbolsWatcher = nullptr; + + if (m_cache[root].generation != m_symbolsJobGen) { + return; + } + Entry &e = m_cache[root]; + e.files = r.first; + e.filesValid = true; + e.symbols = r.second; + e.symbolsValid = true; + Q_EMIT symbolsReady(root, r.second); + }); + m_symbolsWatcher->setFuture(QtConcurrent::run([root, haveFiles, knownFiles]() { + const QStringList files = + haveFiles ? knownFiles : ProjectIndex::listFiles(root); + const QList symbols = SymbolIndex::listSymbols(root, files); + return Result(files, symbols); + })); +} + +} // namespace katecustom diff --git a/src/project/projectindexer.h b/src/project/projectindexer.h new file mode 100644 index 0000000..5f709ff --- /dev/null +++ b/src/project/projectindexer.h @@ -0,0 +1,92 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * ProjectIndexer — asynchronous, cached file/symbol indexing for the switchers. + * + * The file list (git/walk) and the symbol list (ctags) each take tens of + * milliseconds to ~1 s on a large project. Running them synchronously when the + * palette opens freezes the UI. ProjectIndexer runs them on the thread pool + * (QtConcurrent) and delivers results on the owning (UI) thread via signals, + * caching per project root so a second open is instant. + * + * It deals only in raw data (QStringList files, QList symbols); the + * caller builds PaletteItems, keeping project_lib free of any palette/GUI + * dependency. A per-root generation counter drops results from superseded or + * invalidated requests. + */ +#ifndef KATECUSTOM_PROJECTINDEXER_H +#define KATECUSTOM_PROJECTINDEXER_H + +#include "symbolindex.h" + +#include +#include +#include +#include + +template class QFutureWatcher; + +namespace katecustom +{ + +class ProjectIndexer : public QObject +{ + Q_OBJECT + +public: + explicit ProjectIndexer(QObject *parent = nullptr); + ~ProjectIndexer() override; + + /*! True if a valid cached file list exists for \a root. */ + bool hasFiles(const QString &root) const; + /*! True if a valid cached symbol list exists for \a root. */ + bool hasSymbols(const QString &root) const; + + /*! Cached file list for \a root (empty if not cached). */ + QStringList cachedFiles(const QString &root) const; + /*! Cached symbol list for \a root (empty if not cached). */ + QList cachedSymbols(const QString &root) const; + + /*! + * Ensure a file list for \a root. Emits filesReady immediately from cache, + * otherwise launches a background job and emits when it finishes. + */ + void requestFiles(const QString &root); + + /*! + * Ensure a symbol list for \a root (also populates the file cache as a + * byproduct). Emits symbolsReady from cache or after a background job. + */ + void requestSymbols(const QString &root); + + /*! Drop any cached/in-flight data for \a root (e.g. the project changed). */ + void invalidate(const QString &root); + /*! Drop everything. */ + void invalidateAll(); + +Q_SIGNALS: + void filesReady(const QString &root, const QStringList &files); + void symbolsReady(const QString &root, const QList &symbols); + +private: + struct Entry { + QStringList files; + QList symbols; + bool filesValid = false; + bool symbolsValid = false; + quint64 generation = 0; // bumped on invalidate to drop stale results + }; + + QHash m_cache; + QFutureWatcher *m_filesWatcher = nullptr; + // Symbol jobs return both the files used and the symbols found. + QFutureWatcher>> *m_symbolsWatcher = nullptr; + QString m_filesJobRoot; + QString m_symbolsJobRoot; + quint64 m_filesJobGen = 0; + quint64 m_symbolsJobGen = 0; +}; + +} // namespace katecustom + +#endif diff --git a/src/project/symbolindex.h b/src/project/symbolindex.h index a1b26a0..940bc3f 100644 --- a/src/project/symbolindex.h +++ b/src/project/symbolindex.h @@ -16,6 +16,7 @@ #define KATECUSTOM_SYMBOLINDEX_H #include +#include #include #include @@ -37,6 +38,13 @@ struct Symbol { } }; +} // namespace katecustom + +Q_DECLARE_METATYPE(katecustom::Symbol) + +namespace katecustom +{ + class SymbolIndex { public: diff --git a/src/project/test_projectindexer.cpp b/src/project/test_projectindexer.cpp new file mode 100644 index 0000000..e3235be --- /dev/null +++ b/src/project/test_projectindexer.cpp @@ -0,0 +1,127 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * Unit tests for ProjectIndexer: asynchronous file/symbol indexing, caching, + * and invalidation. Uses QSignalSpy::wait to pump the event loop while the + * background QtConcurrent job runs. + */ +#include "projectindexer.h" + +#include +#include +#include +#include +#include + +using namespace katecustom; + +namespace +{ +void writeFile(const QString &path, const QByteArray &data) +{ + QFile f(path); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write(data); + f.close(); +} +} // namespace + +class TestProjectIndexer : public QObject +{ + Q_OBJECT + + QTemporaryDir m_tmp; + +private Q_SLOTS: + void initTestCase() + { + QVERIFY(m_tmp.isValid()); + QDir base(m_tmp.path()); + QVERIFY(base.mkpath(QStringLiteral("src"))); + writeFile(base.filePath(QStringLiteral("README.md")), "hi"); + writeFile(base.filePath(QStringLiteral("src/a.cpp")), + "int alpha(int x){return x;}\n"); + writeFile(base.filePath(QStringLiteral("src/b.cpp")), + "class Beta { public: void gamma(); };\n"); + } + + void requestFilesIsAsyncThenCached() + { + ProjectIndexer idx; + QVERIFY(!idx.hasFiles(m_tmp.path())); + + QSignalSpy spy(&idx, &ProjectIndexer::filesReady); + idx.requestFiles(m_tmp.path()); + // Not cached yet: the result arrives via the event loop, not inline. + QVERIFY(spy.isEmpty() || spy.count() == 0); + QVERIFY(spy.wait(10000)); + QCOMPARE(spy.count(), 1); + + const auto args = spy.takeFirst(); + QCOMPARE(args.at(0).toString(), m_tmp.path()); + const QStringList files = args.at(1).toStringList(); + QVERIFY(files.contains(QStringLiteral("README.md"))); + QVERIFY(files.contains(QStringLiteral("src/a.cpp"))); + + // Now cached: a second request emits synchronously (same call stack). + QVERIFY(idx.hasFiles(m_tmp.path())); + QSignalSpy spy2(&idx, &ProjectIndexer::filesReady); + idx.requestFiles(m_tmp.path()); + QCOMPARE(spy2.count(), 1); // emitted inline from cache, no wait needed + } + + void requestSymbolsIsAsyncThenCached() + { + if (!SymbolIndex::ctagsAvailable()) { + QSKIP("ctags not available"); + } + ProjectIndexer idx; + QSignalSpy spy(&idx, &ProjectIndexer::symbolsReady); + idx.requestSymbols(m_tmp.path()); + QVERIFY(spy.wait(20000)); + QCOMPARE(spy.count(), 1); + + const auto args = spy.takeFirst(); + QCOMPARE(args.at(0).toString(), m_tmp.path()); + const auto symbols = args.at(1).value>(); + QVERIFY(!symbols.isEmpty()); + bool haveAlpha = false; + for (const Symbol &s : symbols) { + if (s.name == QLatin1String("alpha")) { + haveAlpha = true; + } + } + QVERIFY(haveAlpha); + + // Symbol job also populated the file cache as a byproduct. + QVERIFY(idx.hasFiles(m_tmp.path())); + QVERIFY(idx.hasSymbols(m_tmp.path())); + } + + void invalidateDropsCache() + { + ProjectIndexer idx; + QSignalSpy spy(&idx, &ProjectIndexer::filesReady); + idx.requestFiles(m_tmp.path()); + QVERIFY(spy.wait(10000)); + QVERIFY(idx.hasFiles(m_tmp.path())); + + idx.invalidate(m_tmp.path()); + QVERIFY(!idx.hasFiles(m_tmp.path())); + + idx.invalidateAll(); // safe no-op on already-clear state + QVERIFY(!idx.hasFiles(m_tmp.path())); + } + + void emptyRootEmitsEmptyImmediately() + { + ProjectIndexer idx; + QSignalSpy spy(&idx, &ProjectIndexer::filesReady); + idx.requestFiles(QString()); + QCOMPARE(spy.count(), 1); // inline, no background job + QVERIFY(spy.takeFirst().at(1).toStringList().isEmpty()); + } +}; + +QTEST_MAIN(TestProjectIndexer) +#include "test_projectindexer.moc"