Perf: async + cached project indexing (fixes Alt+G open stall)

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<Symbol> metatype; link Qt6::Concurrent.
Item building factored into buildFileItems/buildSymbolItems (shared by
the cache and async paths).
This commit is contained in:
Levi Neely 2026-10-08 11:02:52 +02:00
parent 8dd2995e36
commit 2bf8d4a3c2
10 changed files with 523 additions and 47 deletions

View File

@ -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)

View File

@ -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).

View File

@ -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):

View File

@ -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<PaletteItem> OllieView::buildFileItems(const QString &root,
const QStringList &files) const
{
const qint64 now = QDateTime::currentSecsSinceEpoch();
QList<PaletteItem> 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<PaletteItem> 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<PaletteItem> OllieView::buildSymbolItems(const QString &root,
const QList<Symbol> &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<Symbol> symbols = SymbolIndex::listSymbols(m_symbolSwitcherRoot, files);
const qint64 now = QDateTime::currentSecsSinceEpoch();
QList<PaletteItem> 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<Symbol> &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)

View File

@ -15,6 +15,8 @@
#include "frecencystore.h"
#include "kateprojectbridge.h"
#include "projectindexer.h"
#include "palettemodel.h" // PaletteItem
#include <QObject>
#include <QPointer>
@ -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<PaletteItem> buildFileItems(const QString &root, const QStringList &files) const;
QList<PaletteItem> buildSymbolItems(const QString &root, const QList<Symbol> &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<QString, QPointer<QAction>> 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

View File

@ -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

View File

@ -0,0 +1,166 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "projectindexer.h"
#include "projectindex.h"
#include <QFutureWatcher>
#include <QPair>
#include <QtConcurrent/QtConcurrentRun>
namespace katecustom
{
ProjectIndexer::ProjectIndexer(QObject *parent)
: QObject(parent)
{
// Needed so QList<Symbol> can travel through QVariant / queued signals and
// be captured by QSignalSpy in tests.
qRegisterMetaType<Symbol>("katecustom::Symbol");
qRegisterMetaType<QList<Symbol>>("QList<katecustom::Symbol>");
}
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<Symbol> 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<QStringList>(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<Symbol>());
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<QStringList, QList<Symbol>>;
m_symbolsWatcher = new QFutureWatcher<Result>(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<Symbol> symbols = SymbolIndex::listSymbols(root, files);
return Result(files, symbols);
}));
}
} // namespace katecustom

View File

@ -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<Symbol> 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 <QHash>
#include <QObject>
#include <QString>
#include <QStringList>
template<typename T> 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<Symbol> 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<Symbol> &symbols);
private:
struct Entry {
QStringList files;
QList<Symbol> symbols;
bool filesValid = false;
bool symbolsValid = false;
quint64 generation = 0; // bumped on invalidate to drop stale results
};
QHash<QString, Entry> m_cache;
QFutureWatcher<QStringList> *m_filesWatcher = nullptr;
// Symbol jobs return both the files used and the symbols found.
QFutureWatcher<QPair<QStringList, QList<Symbol>>> *m_symbolsWatcher = nullptr;
QString m_filesJobRoot;
QString m_symbolsJobRoot;
quint64 m_filesJobGen = 0;
quint64 m_symbolsJobGen = 0;
};
} // namespace katecustom
#endif

View File

@ -16,6 +16,7 @@
#define KATECUSTOM_SYMBOLINDEX_H
#include <QList>
#include <QMetaType>
#include <QString>
#include <QStringList>
@ -37,6 +38,13 @@ struct Symbol {
}
};
} // namespace katecustom
Q_DECLARE_METATYPE(katecustom::Symbol)
namespace katecustom
{
class SymbolIndex
{
public:

View File

@ -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 <QDir>
#include <QFile>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QTest>
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<QList<Symbol>>();
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"