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:
parent
8dd2995e36
commit
2bf8d4a3c2
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
20
docs/PERF.md
20
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).
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue