From dca2ffdd5b5821a311a146d980fe29d43e573c51 Mon Sep 17 00:00:00 2001 From: Edu Meneses Date: Mon, 22 Jun 2026 17:15:12 -0400 Subject: [PATCH] library: search processes by tags, not just name The process library filter only matched against the displayed name. Per review, descriptor() must never be called on the search path: it can be extremely slow (it may parse the file behind the node, e.g. ISF/Faust) and search is already too slow as-is. Instead, implement the two-level scan suggested in the review: 1. The existing fast scan stays untouched: names appear immediately. 2. A second, deeper scan runs on a worker thread and computes a lower-case '|'-separated searchString (name, description, tags) per node by calling descriptor() there. Results are delivered to the GUI thread in packets of 100, like RecursiveWatch does, to avoid clobbering the main loop. Since some scanners (LV2, RecursiveWatch commits...) add nodes without begin/endInsertRows, there is no reliable "node added" signal: a 3s timer polls for unindexed nodes; the sweep is a cheap in-memory tree walk once everything is indexed. Node pointers handed to the worker are guarded by a generation counter bumped on model reset/row removal. The filter then matches prettyName and searchString purely in-memory. Fixes #1910 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sn5p9zXz8ZvWcC14x8hiWg --- .../Library/ProcessesItemModel.cpp | 107 ++++++++++++++++++ .../Library/ProcessesItemModel.hpp | 24 ++++ .../Library/RecursiveFilterProxy.cpp | 8 +- 3 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp b/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp index 7171dcf8b9..9690871a95 100644 --- a/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp +++ b/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include namespace Library @@ -35,15 +36,121 @@ ProcessesItemModel::ProcessesItemModel( const score::GUIApplicationContext& ctx, QObject* parent) : TreeNodeBasedItemModel{parent} , context{ctx} + , m_alive{std::make_shared(true)} { auto& procs = ctx.interfaces(); procs.added.connect<&ProcessesItemModel::on_newPlugin>(*this); + // Node pointers handed to the search indexing worker are invalidated by + // resets and removals; drop any in-flight result when that happens. + auto invalidate = [this] { ++m_searchIndexGeneration; }; + connect(this, &QAbstractItemModel::modelAboutToBeReset, this, invalidate); + connect(this, &QAbstractItemModel::rowsAboutToBeRemoved, this, invalidate); + + // Some scanners (LV2, RecursiveWatch commits...) add nodes without + // begin/endInsertRows, so there is no reliable "node added" signal: poll + // instead. The sweep is a cheap in-memory tree walk when there is nothing + // new to index. + m_searchIndexTimer.setInterval(3000); + connect(&m_searchIndexTimer, &QTimer::timeout, this, [this] { indexForSearch(); }); + m_searchIndexTimer.start(); + auto& lib = context.settings(); con(lib, &Library::Settings::Model::rescanLibrary, this, &ProcessesItemModel::rescan); rescan(); } +ProcessesItemModel::~ProcessesItemModel() +{ + *m_alive = false; +} + +void ProcessesItemModel::indexForSearch() +{ + if(m_searchIndexRunning) + return; + + struct Item + { + ProcessNode* node{}; + Process::ProcessModelFactory* factory{}; + QString customData; + QString prettyName; + }; + + // GUI thread: collect the nodes which still need their search data, and + // resolve their factory here so that the worker never touches the + // interface list (which can grow when addons are loaded). + auto items = std::make_shared>(); + auto& factories = context.interfaces(); + auto collect = [&](auto&& self, ProcessNode& node) -> void { + if(node.key != Process::ProcessModelFactory::ConcreteKey{} + && node.searchString.isEmpty()) + { + if(auto* f = factories.get(node.key)) + items->push_back({&node, f, node.customData, node.prettyName}); + } + for(auto& child : node) + self(self, child); + }; + collect(collect, m_root); + + if(items->empty()) + return; + + m_searchIndexRunning = true; + const auto generation = m_searchIndexGeneration; + + // Worker thread: descriptor() is potentially very slow (it may parse the + // file behind the node); this is the whole reason this runs off the GUI + // thread. Results are delivered in packets to avoid clobbering the main + // loop with tens of thousands of queued events. + QThreadPool::globalInstance()->start( + [this, items = std::move(items), generation, alive = m_alive] { + constexpr std::size_t packetSize = 100; + std::vector> packet; + packet.reserve(packetSize); + + auto flush = [&] { + if(packet.empty()) + return; + QMetaObject::invokeMethod( + this, [this, generation, packet = std::move(packet)] { + if(generation != m_searchIndexGeneration) + return; + for(const auto& [node, str] : packet) + node->searchString = str; + }, Qt::QueuedConnection); + packet.clear(); + packet.reserve(packetSize); + }; + + for(const Item& item : *items) + { + if(!*alive) + return; + + const auto desc = item.factory->descriptor(item.customData); + QStringList parts{item.prettyName, desc.prettyName, desc.description}; + parts += desc.tags; + parts.removeAll(QString{}); + parts.removeDuplicates(); + + // Never leave the string empty: an empty searchString means + // "not indexed yet" and the node would be rescanned forever. + QString str = parts.isEmpty() ? QStringLiteral("|") : parts.join(u'|').toLower(); + + packet.emplace_back(item.node, std::move(str)); + if(packet.size() >= packetSize) + flush(); + } + flush(); + + QMetaObject::invokeMethod( + this, [this] { m_searchIndexRunning = false; }, Qt::QueuedConnection); + }); +} + ProcessNode& ProcessesItemModel::addCategory(const QString& c) { auto split = c.split("/"); diff --git a/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp b/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp index f206925262..0f1c612b20 100644 --- a/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp +++ b/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp @@ -16,6 +16,10 @@ #include #include +#include + +#include +#include #include #include @@ -32,6 +36,12 @@ namespace Library struct ProcessData : Process::ProcessData { QIcon icon; + + //! Lower-case, '|'-separated search data (name, description, tags...). + //! Empty until the async deep scan has indexed this node; the search + //! filter then matches against it in-memory, without ever calling + //! ProcessModelFactory::descriptor() on the search path. + QString searchString; }; using ProcessNode = TreeNode; @@ -54,6 +64,7 @@ class SCORE_PLUGIN_LIBRARY_EXPORT ProcessesItemModel using QAbstractItemModel::endResetModel; ProcessesItemModel(const score::GUIApplicationContext& ctx, QObject* parent); + ~ProcessesItemModel(); void rescan(); QModelIndex find(const Process::ProcessModelFactory::ConcreteKey& k); @@ -77,8 +88,21 @@ class SCORE_PLUGIN_LIBRARY_EXPORT ProcessesItemModel private: ProcessNode& addCategory(const QString& cat); + + // Second-level async scan: computes ProcessData::searchString (tags, + // description...) on a worker thread, off the fast name-only scan and off + // the search path. Results come back to the GUI thread in packets. + void indexForSearch(); + const score::GUIApplicationContext& context; ProcessNode m_root; + + QTimer m_searchIndexTimer; + //! Bumped whenever ProcessNode pointers may dangle (model reset, row + //! removal); in-flight indexing results from older generations are dropped. + uint64_t m_searchIndexGeneration{}; + bool m_searchIndexRunning{}; + std::shared_ptr m_alive; }; /** Utility class to organize a library in subcategories that depend diff --git a/src/plugins/score-plugin-library/Library/RecursiveFilterProxy.cpp b/src/plugins/score-plugin-library/Library/RecursiveFilterProxy.cpp index 6c8e843b22..9c8c7ee064 100644 --- a/src/plugins/score-plugin-library/Library/RecursiveFilterProxy.cpp +++ b/src/plugins/score-plugin-library/Library/RecursiveFilterProxy.cpp @@ -77,6 +77,12 @@ bool ProcessFilterProxy::filterAcceptsRowItself( auto model = static_cast(sourceModel()); auto& node = model->nodeFromModelIndex(index); - return node.prettyName.contains(m_textPattern, Qt::CaseInsensitive); + if(node.prettyName.contains(m_textPattern, Qt::CaseInsensitive)) + return true; + + // Also match against the search data (tags, description...) computed + // asynchronously by ProcessesItemModel::indexForSearch(). Purely in-memory: + // the search path never calls into the factories. + return node.searchString.contains(m_textPattern, Qt::CaseInsensitive); } }