perf(wallets): walk added wallet folders off the UI thread
Every open of the Wallets dialog, Refresh, folder add or remove, and Open on a stale row walked each user-added folder recursively on the UI thread before the dialog drew: up to 40,000 entries per folder, roughly 50-150 ms warm on Linux and seconds on a cold disk, a network share or Windows. The datadir listing (a few top-level files) stays synchronous, so a wallet just created shows at once. The recursive walk runs on a detached worker with the same depth, hit and entry limits; its rows are merged at the start of a later frame and only the new rows are probed. - The dialog opens at once with the datadir rows. The external rows arrive when the walk ends, with "Scanning folders..." beside the scanned-folders caption, and the card grows once. - Reopening within 60 s with the same folders reuses the last walk without touching the disk, so its rows and sizes can be up to a minute stale: a wallet copied into a folder in that minute needs Refresh. Refresh and Open on a stale row always walk again. - While a walk or a wallet probe runs (up to 30 s) the dialog keeps frames coming, so rows and badges appear without moving the mouse. - The 200-row cap now holds across all folders; each extra folder could add one row past it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -383,6 +383,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["wallets_external_tt"] = "Outside your data directory \xE2\x80\x94 Open links it in place (no copy).";
|
||||
strings_["wallets_scan_folder"] = "Scan another folder for wallets\xE2\x80\xA6";
|
||||
strings_["wallets_scanned_folders"] = "Scanned folders:";
|
||||
strings_["wallets_scanning"] = "Scanning folders\xE2\x80\xA6";
|
||||
strings_["wallets_remove_folder"] = "Stop scanning this folder";
|
||||
strings_["wallets_empty_hint"] = "Scan a folder to find more wallets";
|
||||
strings_["wallets_folder_invalid"] = "That folder doesn't exist.";
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// wallet_folder_scan.h - enumerate wallet .dat files in the datadir (top level) and in user-added folders
|
||||
// (bounded recursive walk). Pure filesystem logic (no UI, no App), moved verbatim out of
|
||||
// WalletsDialog::scan() so the external walk can run on a worker thread and be unit-tested.
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "wallet_file_probe.h" // isInPlaceLinkName
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
struct ScannedWallet {
|
||||
std::string fileName;
|
||||
std::string dir; // the file's ACTUAL parent (may be a subdir) — Open reads dir + "/" + fileName
|
||||
std::string canonPath; // symlink-resolved, absolute: the STABLE identity of the physical file (two paths
|
||||
// reaching one wallet share it); used for the in-place link name + row de-dup
|
||||
bool inDatadir = false;
|
||||
long long sizeBytes = 0;
|
||||
};
|
||||
|
||||
struct WalletScanLimits {
|
||||
int maxDepth = 8; // don't descend deeper than this below a chosen folder
|
||||
std::size_t maxHits = 200; // cap on total rows listed (datadir + external)
|
||||
int maxVisited = 40000; // cap on entries walked per external folder
|
||||
};
|
||||
|
||||
// Node/daemon .dat files that are NOT wallets (a user can point the picker at a folder that IS or CONTAINS a
|
||||
// datadir, so these are filtered everywhere or the list fills with blk*/rev* block files, peers.dat, …).
|
||||
inline bool isWalletNodeArtifact(const std::string& n) {
|
||||
static const char* kExact[] = { "peers.dat", "banlist.dat", "fee_estimates.dat",
|
||||
"mempool.dat", "asmap.dat", "zindex.dat" };
|
||||
for (const char* e : kExact) if (n == e) return true;
|
||||
return n.rfind("blk", 0) == 0 || n.rfind("rev", 0) == 0; // blk00000.dat / rev00000.dat
|
||||
}
|
||||
// Directory names a recursive external scan never descends into (no user wallets; would burn the budget).
|
||||
inline bool isWalletNodeSubtree(const std::string& n) {
|
||||
return n == "blocks" || n == "chainstate" || n == "database" || n == "indexes" || n == "index";
|
||||
}
|
||||
|
||||
inline std::string canonicalWalletPath(const std::filesystem::path& p) {
|
||||
namespace fs = std::filesystem;
|
||||
std::error_code ec;
|
||||
fs::path c = fs::canonical(p, ec);
|
||||
if (ec) c = fs::weakly_canonical(p, ec);
|
||||
return c.empty() ? p.string() : c.string();
|
||||
}
|
||||
|
||||
// String checks FIRST (no syscalls; most entries in a big tree are neither .dat nor wallets), then stat /
|
||||
// canonical only for plausible candidates. Datadir: wallet-prefixed, in-place links hidden. External: any
|
||||
// .dat except node junk. One row per physical wallet via seenCanon.
|
||||
inline bool considerWalletFile(const std::filesystem::path& p, bool inDatadir,
|
||||
std::unordered_set<std::string>& seenCanon, ScannedWallet& out) {
|
||||
namespace fs = std::filesystem;
|
||||
const std::string name = p.filename().string();
|
||||
if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") return false;
|
||||
if (inDatadir) { if (name.rfind("wallet", 0) != 0 || isInPlaceLinkName(name)) return false; }
|
||||
else { if (isWalletNodeArtifact(name)) return false; }
|
||||
std::error_code ec;
|
||||
if (!fs::is_regular_file(p, ec)) return false;
|
||||
ScannedWallet r;
|
||||
r.fileName = name;
|
||||
r.dir = p.parent_path().string();
|
||||
r.canonPath = canonicalWalletPath(p);
|
||||
if (!seenCanon.insert(r.canonPath).second) return false; // same physical wallet already listed
|
||||
r.inDatadir = inDatadir;
|
||||
r.sizeBytes = static_cast<long long>(fs::file_size(p, ec));
|
||||
out = std::move(r);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Top-level wallet*.dat files of the datadir (never descends into blocks/ chainstate/ …). Cheap: UI thread.
|
||||
inline std::vector<ScannedWallet> scanDatadirWallets(const std::string& datadir,
|
||||
std::unordered_set<std::string>& seenCanon) {
|
||||
namespace fs = std::filesystem;
|
||||
std::vector<ScannedWallet> out;
|
||||
std::error_code ec;
|
||||
if (datadir.empty() || !fs::is_directory(datadir, ec)) return out;
|
||||
fs::directory_iterator it(datadir, ec), end;
|
||||
for (; !ec && it != end; it.increment(ec)) {
|
||||
ScannedWallet w;
|
||||
if (considerWalletFile(it->path(), true, seenCanon, w)) out.push_back(std::move(w));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Bounded recursive walk of the user-added folders. Safe off the UI thread: touches only its arguments.
|
||||
// `cancel` is polled per entry. Directory symlinks are not followed (std default) so cycles can't loop;
|
||||
// permission-denied subtrees are skipped. `alreadyListed` (the caller's datadir rows) counts toward maxHits.
|
||||
inline std::vector<ScannedWallet> scanExternalWalletFolders(const std::vector<std::string>& folders,
|
||||
const WalletScanLimits& lim,
|
||||
std::unordered_set<std::string>& seenCanon,
|
||||
std::size_t alreadyListed,
|
||||
const std::atomic<bool>* cancel = nullptr) {
|
||||
namespace fs = std::filesystem;
|
||||
std::vector<ScannedWallet> out;
|
||||
auto cancelled = [&]() { return cancel && cancel->load(std::memory_order_relaxed); };
|
||||
for (const auto& dir : folders) {
|
||||
if (cancelled()) break;
|
||||
std::error_code ec;
|
||||
if (dir.empty() || !fs::is_directory(dir, ec)) continue;
|
||||
fs::recursive_directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec), end;
|
||||
int visited = 0;
|
||||
for (; !ec && it != end; it.increment(ec)) {
|
||||
if (cancelled()) return out;
|
||||
if (++visited > lim.maxVisited) break;
|
||||
std::error_code dec;
|
||||
if (it->is_directory(dec)) {
|
||||
if (it.depth() >= lim.maxDepth || isWalletNodeSubtree(it->path().filename().string()))
|
||||
it.disable_recursion_pending();
|
||||
continue; // directories aren't wallet files
|
||||
}
|
||||
ScannedWallet w;
|
||||
if (considerWalletFile(it->path(), false, seenCanon, w)) out.push_back(std::move(w));
|
||||
if (alreadyListed + out.size() >= lim.maxHits) return out;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// External-result cache policy: reuse only when not forced, the folder list is unchanged and the result is
|
||||
// younger than the TTL. Refresh and a stale-row Open force; add/remove folder change the key.
|
||||
inline std::string externalScanKey(const std::vector<std::string>& folders) {
|
||||
std::string k;
|
||||
for (const auto& f : folders) { k += f; k += '\n'; }
|
||||
return k;
|
||||
}
|
||||
inline bool externalScanCacheUsable(bool valid, const std::string& cachedKey, const std::string& key,
|
||||
double ageSeconds, double ttlSeconds, bool force) {
|
||||
return valid && !force && cachedKey == key && ageSeconds >= 0.0 && ageSeconds < ttlSeconds;
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
Reference in New Issue
Block a user