fix(ui): prevent stuck spinners and double-submits in dialogs

Address a cluster of low-severity robustness nits where an async
callback could be skipped (leaving a spinner spinning forever) or a
synchronous action could be re-fired mid-flight:

- app_network: add catch(...) fallbacks around the importPrivateKey and
  submitZSendMany worker lambdas so a non-std throw can't escape the
  worker and skip the main-thread callback (stuck "Importing…"/"Sending…").
- export_transactions: re-entrancy guard disabling Export while a write
  is in flight.
- bootstrap_download: disable Cancel once clicked so the request is
  visibly acknowledged and can't be re-fired before the worker stops.
- network_tab: give clear "already in list" feedback on a duplicate
  server (keep the inputs, clear only the stale invalid-URL error) and
  disable/relabel Refresh while a probe is in flight.
- key_export: offer Retry from the error branch (falls back to Reveal)
  and guard against an empty/malformed address before dispatching.
- receive_tab: only enter the "generating…" state when a dispatch is
  actually possible (guards a stuck spinner when disconnected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 15:08:43 -05:00
co-authored by Claude Opus 4.8
parent 1dbbd44759
commit 41f5548593
6 changed files with 86 additions and 30 deletions
+36 -22
View File
@@ -290,27 +290,34 @@ static void RenderAddressDropdown(App* app, float width) {
snprintf(genLabel, sizeof(genLabel), "%s%s##recv", TR("generating"), material::LoadingDots());
TactileButton(genLabel, ImVec2(newBtnW, 0), schema::UI().resolveFont("button"));
} else if (TactileButton(TrId("new", "recv").c_str(), ImVec2(newBtnW, 0), schema::UI().resolveFont("button"))) {
s_generating_address = true;
if (s_addr_type_filter != 2) {
app->createNewZAddress([](const std::string& addr) {
s_generating_address = false;
if (addr.empty())
Notifications::instance().error(TR("failed_create_shielded"));
else {
s_pending_select_address = addr;
Notifications::instance().success(TR("new_shielded_created"));
}
});
// createNewAddress can early-return without ever invoking the callback (e.g. connection
// dropped between this button being enabled and the click landing), which would leave
// s_generating_address stuck. Only enter the "generating…" state when a dispatch is possible.
if (!app->isConnected()) {
s_generating_address = false;
} else {
app->createNewTAddress([](const std::string& addr) {
s_generating_address = false;
if (addr.empty())
Notifications::instance().error(TR("failed_create_transparent"));
else {
s_pending_select_address = addr;
Notifications::instance().success(TR("new_transparent_created"));
}
});
s_generating_address = true;
if (s_addr_type_filter != 2) {
app->createNewZAddress([](const std::string& addr) {
s_generating_address = false;
if (addr.empty())
Notifications::instance().error(TR("failed_create_shielded"));
else {
s_pending_select_address = addr;
Notifications::instance().success(TR("new_shielded_created"));
}
});
} else {
app->createNewTAddress([](const std::string& addr) {
s_generating_address = false;
if (addr.empty())
Notifications::instance().error(TR("failed_create_transparent"));
else {
s_pending_select_address = addr;
Notifications::instance().success(TR("new_transparent_created"));
}
});
}
}
}
ImGui::EndDisabled();
@@ -601,6 +608,7 @@ void RenderReceiveTab(App* app)
if (s_request_usd_mode && usd_price > 0) {
ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##RequestAmountUSD", &s_request_usd_amount, 0, 0, "$%.2f")) {
if (s_request_usd_amount < 0) s_request_usd_amount = 0;
s_request_amount = s_request_usd_amount / usd_price;
}
{
@@ -617,6 +625,7 @@ void RenderReceiveTab(App* app)
} else {
ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##RequestAmount", &s_request_amount, 0, 0, "%.8f")) {
if (s_request_amount < 0) s_request_amount = 0;
if (usd_price > 0)
s_request_usd_amount = s_request_amount * usd_price;
}
@@ -763,9 +772,14 @@ void RenderReceiveTab(App* app)
ImVec2(addrColW, memoInputH));
ImGui::PopItemWidth();
// The Sapling memo limit is in BYTES and matches the enforced buffer size
// (InputTextMultiline caps at sizeof(s_request_memo)); count against that, not the
// old display-only 256 value, and warn in colour once the field is at its cap.
size_t memo_len = strlen(s_request_memo);
snprintf(buf, sizeof(buf), "%zu / %d", memo_len, (int)S.drawElement("tabs.receive", "memo-max-display-chars").size);
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
size_t memoMax = (size_t)S.drawElement("business", "memo-max-length").size;
bool memoAtCap = memo_len + 1 >= memoMax;
snprintf(buf, sizeof(buf), "%zu / %zu bytes", memo_len, memoMax);
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
}
// URI preview