From 168cae9306e622cfe5aa04adcbc51bf8bb150113 Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 13 Jun 2026 12:38:24 -0500 Subject: [PATCH] =?UTF-8?q?feat(explorer):=20fuzzy=20search=20=E2=80=94=20?= =?UTF-8?q?filter=20the=20block=20list=20by=20partial=20hash/height?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a fuzzy mode to the explorer search: a non-numeric, non-full-hash query now filters the list to cached blocks whose hash (or height text) contains the query substring, live as you type. Backed by a new ExplorerBlockCache::searchBlocks() (SQLite LIKE with escaped wildcards), memoized per query so it doesn't hit the DB every frame. Exact queries still navigate precisely: a block height re-anchors the list, and a full 64-char hash is resolved via RPC. Row clicks still open the detail modal. Empty results show "No matching cached blocks". Note: fuzzy matching covers cached (browsed/prefetched) blocks only — the daemon has no partial-hash index — while exact height/hash lookups reach any block. Co-Authored-By: Claude Opus 4.8 --- src/ui/explorer/explorer_block_cache.cpp | 39 ++++++++++++++++++++ src/ui/explorer/explorer_block_cache.h | 4 +++ src/ui/windows/explorer_tab.cpp | 45 ++++++++++++++++++++---- src/util/i18n.cpp | 1 + 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/ui/explorer/explorer_block_cache.cpp b/src/ui/explorer/explorer_block_cache.cpp index 1333fa5..20ca5da 100644 --- a/src/ui/explorer/explorer_block_cache.cpp +++ b/src/ui/explorer/explorer_block_cache.cpp @@ -153,6 +153,45 @@ std::map ExplorerBlockCache::loadRange(int minHeight, return blocks; } +std::vector ExplorerBlockCache::searchBlocks(const std::string& query, int limit) +{ + std::vector results; + if (query.empty() || limit < 1 || !ensureOpen()) return results; + + // Escape LIKE wildcards in the user input so '%' / '_' are matched literally. + std::string escaped; + escaped.reserve(query.size()); + for (char c : query) { + if (c == '%' || c == '_' || c == '\\') escaped.push_back('\\'); + escaped.push_back(c); + } + std::string pattern = "%" + escaped + "%"; + + Statement statement(db_, + "SELECT height, hash, tx_count, size, time, difficulty " + "FROM explorer_blocks " + "WHERE CAST(height AS TEXT) LIKE ?1 ESCAPE '\\' OR hash LIKE ?1 ESCAPE '\\' " + "ORDER BY height DESC LIMIT ?2"); + if (!statement.handle) return results; + + sqlite3_bind_text(statement.handle, 1, pattern.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(statement.handle, 2, limit); + + while (sqlite3_step(statement.handle) == SQLITE_ROW) { + ExplorerBlockSummary block; + block.height = sqlite3_column_int(statement.handle, 0); + const unsigned char* hashText = sqlite3_column_text(statement.handle, 1); + if (hashText) block.hash = reinterpret_cast(hashText); + block.tx_count = sqlite3_column_int(statement.handle, 2); + block.size = sqlite3_column_int(statement.handle, 3); + block.time = static_cast(sqlite3_column_int64(statement.handle, 4)); + block.difficulty = sqlite3_column_double(statement.handle, 5); + if (block.height > 0 && !block.hash.empty()) results.push_back(std::move(block)); + } + + return results; +} + bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block) { if (block.height < 1 || block.hash.empty() || !ensureOpen()) return false; diff --git a/src/ui/explorer/explorer_block_cache.h b/src/ui/explorer/explorer_block_cache.h index 8354af1..e98e77c 100644 --- a/src/ui/explorer/explorer_block_cache.h +++ b/src/ui/explorer/explorer_block_cache.h @@ -3,6 +3,7 @@ #include #include #include +#include struct sqlite3; @@ -41,6 +42,9 @@ public: const std::string& databasePath() const { return database_path_; } std::map loadRange(int minHeight, int maxHeight); + // Fuzzy search over cached blocks: matches when the query is a substring of the height (as text) + // or the block hash (case-insensitive). Returns newest-first, capped at `limit`. + std::vector searchBlocks(const std::string& query, int limit); bool storeBlock(const ExplorerBlockSummary& block); int cachedBlockCount(); void clearBlocks(); diff --git a/src/ui/windows/explorer_tab.cpp b/src/ui/windows/explorer_tab.cpp index baa1d8e..c89247d 100644 --- a/src/ui/windows/explorer_tab.cpp +++ b/src/ui/windows/explorer_tab.cpp @@ -64,6 +64,12 @@ static ExplorerBlockCache s_recent_block_cache; static std::set s_pending_block_heights; static int s_recent_page = 0; static int s_recent_max_page = 0; +// Fuzzy search: a non-numeric / partial query filters the list to cached blocks whose hash (or +// height text) contains it. Results are memoized per query so we don't hit SQLite every frame. +static std::string s_active_search_query; +static std::string s_fuzzy_query_cached; +static std::vector s_fuzzy_results; +static constexpr int kMaxFuzzyResults = 100; static std::atomic s_pending_block_fetches{0}; static bool s_recent_disk_cache_validation_pending = false; @@ -493,6 +499,7 @@ static void renderSearchBar(App* app, float availWidth) { std::string query(s_search_buf); while (!query.empty() && query.front() == ' ') query.erase(query.begin()); while (!query.empty() && query.back() == ' ') query.pop_back(); + s_active_search_query = query; // drives the fuzzy list filter in renderRecentBlocks if (edited) { s_search_change_time = ImGui::GetTime(); // (re)start the debounce on each keystroke @@ -811,7 +818,24 @@ static void renderRecentBlocks(App* app, float availWidth) { cachedBlocks = s_recent_block_cache.loadRange(pageHeights.back(), pageHeights.front()); } - bool canFetchBlocks = state.sync.blocks > 0 && + // Fuzzy mode: a non-numeric, non-full-hash query filters the list to cached blocks whose hash + // (or height text) contains it. Exact queries (a height or a full 64-char hash) still navigate + // via performSearch/the anchor above. Results are memoized per query (no per-frame SQLite). + const std::string& fq = s_active_search_query; + bool fqDigits = !fq.empty() && std::all_of(fq.begin(), fq.end(), ::isdigit); + bool fqFullHash = fq.size() == 64 && std::all_of(fq.begin(), fq.end(), [](char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + }); + bool fuzzyMode = !fq.empty() && !fqDigits && !fqFullHash; + if (fuzzyMode) { + if (fq != s_fuzzy_query_cached) { + s_fuzzy_results = s_recent_block_cache.searchBlocks(fq, kMaxFuzzyResults); + s_fuzzy_query_cached = fq; + } + s_recent_max_page = 0; // fuzzy results aren't paginated; they scroll + } + + bool canFetchBlocks = !fuzzyMode && state.sync.blocks > 0 && !s_show_detail_modal && !s_detail_loading && !s_tx_loading; if (canFetchBlocks) { bool currentPageComplete = true; @@ -855,11 +879,18 @@ static void renderRecentBlocks(App* app, float availWidth) { }; std::vector blocks; - blocks.reserve(pageHeights.size()); - for (int height : pageHeights) { - auto it = cachedBlocks.find(height); - blocks.push_back({height, - it != cachedBlocks.end() ? &it->second : nullptr}); + if (fuzzyMode) { + blocks.reserve(s_fuzzy_results.size()); + for (const auto& b : s_fuzzy_results) { + blocks.push_back({b.height, &b}); + } + } else { + blocks.reserve(pageHeights.size()); + for (int height : pageHeights) { + auto it = cachedBlocks.find(height); + blocks.push_back({height, + it != cachedBlocks.end() ? &it->second : nullptr}); + } } // Responsive column layout — give height more room, use remaining for data float innerW = availWidth - pad * 2; @@ -895,7 +926,7 @@ static void renderRecentBlocks(App* app, float availWidth) { if (blocks.empty()) { ImGui::SetCursorPosY(Layout::spacingMd()); - ImGui::TextDisabled("%s", TR("loading")); + ImGui::TextDisabled("%s", fuzzyMode ? TR("explorer_no_results") : TR("loading")); } else { for (size_t i = 0; i < blocks.size(); i++) { const auto& row = blocks[i]; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index ea754fd..99688a4 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1364,6 +1364,7 @@ void I18n::loadBuiltinEnglish() strings_["explorer_tx_outputs"] = "Outputs"; strings_["explorer_tx_size"] = "Size"; strings_["explorer_invalid_query"] = "Enter a block height or 64-character hash"; + strings_["explorer_no_results"] = "No matching cached blocks"; } const char* I18n::translate(const char* key) const