feat(explorer): fuzzy search — filter the block list by partial hash/height
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 <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,45 @@ std::map<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight,
|
||||
return blocks;
|
||||
}
|
||||
|
||||
std::vector<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::string& query, int limit)
|
||||
{
|
||||
std::vector<ExplorerBlockSummary> 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<const char*>(hashText);
|
||||
block.tx_count = sqlite3_column_int(statement.handle, 2);
|
||||
block.size = sqlite3_column_int(statement.handle, 3);
|
||||
block.time = static_cast<std::int64_t>(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;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
@@ -41,6 +42,9 @@ public:
|
||||
const std::string& databasePath() const { return database_path_; }
|
||||
|
||||
std::map<int, ExplorerBlockSummary> 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<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
|
||||
bool storeBlock(const ExplorerBlockSummary& block);
|
||||
int cachedBlockCount();
|
||||
void clearBlocks();
|
||||
|
||||
@@ -64,6 +64,12 @@ static ExplorerBlockCache s_recent_block_cache;
|
||||
static std::set<int> 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<BlockSummary> s_fuzzy_results;
|
||||
static constexpr int kMaxFuzzyResults = 100;
|
||||
static std::atomic<int> 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<RecentBlockRow> 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];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user