fix(market): scope the portfolio editor to the active wallet

The Market summary filters custom groups by wallet scope, but the editor did
not: its master list showed and edited *every* wallet's groups, and "Manage…"
opened raw storage index 0 — which could belong to a different wallet.
Separately, a group could be persisted with an empty scope (a placeholder
added before the wallet identity resolved, then filled in and saved), which the
filter treats as "legacy" and shows in *every* wallet's list.

- The editor master list now filters to the active wallet's groups (plus
  legacy/unscoped), mirroring the summary. Storage indices are preserved so
  selection/delete still target the right entry.
- "Manage…" opens the first in-scope group (or the empty state), never index 0.
- The selection clamp resets to the first visible group if the current one is
  out of range or out of scope.
- On commit, a group with no scope is claimed for the active wallet when an
  identity is available, so it can't linger in the global bucket.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:08:28 -05:00
parent 3aeb847657
commit 62c92cc862

View File

@@ -349,8 +349,11 @@ static void pfPersist(config::Settings* settings, const std::vector<config::Sett
}
// Persist the working group when it is named and actually changed; unnamed drafts are dropped.
// New groups default to the active wallet's scope (per-wallet visibility); existing groups keep
// whatever scope they already had.
// New groups adopt the active wallet's scope (per-wallet visibility). An existing group with no scope
// — a placeholder added before the wallet identity resolved, or a pre-scoping legacy group — is also
// claimed for the active wallet on edit, so it can't linger in the global (empty-scope) bucket and
// leak into every wallet's list. No-op when the identity is unavailable (stays unscoped until edited
// with a live identity).
static void pfCommitIfNeeded(App* app)
{
config::Settings* settings = app->settings();
@@ -360,12 +363,34 @@ static void pfCommitIfNeeded(App* app)
config::Settings::PortfolioEntry base;
if (existing) { base = entries[s_pfEdit.sel]; if (pfWorkingMatches(base)) return; }
auto e = pfBuildWorking(base);
if (!existing) e.scope = app->activeWalletIdentityHash();
if (e.scope.empty()) {
const std::string h = app->activeWalletIdentityHash();
if (!h.empty()) e.scope = h;
}
if (existing) entries[s_pfEdit.sel] = e;
else { entries.push_back(e); s_pfEdit.sel = (int)entries.size() - 1; }
pfPersist(settings, entries);
}
// A group is visible to the active wallet when it carries the wallet's scope, is unscoped (legacy —
// shown everywhere), or the wallet identity isn't resolved yet. The editor mirrors the Market summary's
// scope filter so it only lists/edits the current wallet's groups.
static bool pfIndexVisible(App* app, int i)
{
const auto& es = app->settings()->getPortfolioEntries();
if (i < 0 || i >= (int)es.size()) return false;
const std::string h = app->activeWalletIdentityHash();
return es[i].scope.empty() || h.empty() || es[i].scope == h;
}
static int pfFirstVisibleIndex(App* app)
{
const auto& es = app->settings()->getPortfolioEntries();
const std::string h = app->activeWalletIdentityHash();
for (int i = 0; i < (int)es.size(); i++)
if (es[i].scope.empty() || h.empty() || es[i].scope == h) return i;
return -1;
}
// ---- Detail-pane section renderers (drawn inside ##pfDetailBody; operate on s_pfEdit) ----
// Appearance: accent-color picker (+ custom), outline-opacity slider, icon grid.
@@ -759,11 +784,12 @@ static void RenderPortfolioEditor(App* app)
}
{ // Clamp/validate the selection. The in-editor delete already reloads the working state, but if the
// list shrank via any other path, reload after clamping so a later commit can't overlay stale
// fields onto the clamped entry.
// list shrank or the selection landed on a group outside this wallet's scope via any other path,
// reset to the first visible group and reload so a later commit can't overlay stale fields.
const auto& entriesRO = settings->getPortfolioEntries();
if (s_pfEdit.sel >= (int)entriesRO.size()) {
s_pfEdit.sel = entriesRO.empty() ? -1 : 0;
if (s_pfEdit.sel >= (int)entriesRO.size() ||
(s_pfEdit.sel >= 0 && !pfIndexVisible(app, s_pfEdit.sel))) {
s_pfEdit.sel = pfFirstVisibleIndex(app);
PortfolioBeginEdit(app, s_pfEdit.sel);
}
}
@@ -792,12 +818,20 @@ static void RenderPortfolioEditor(App* app)
ImGui::BeginChild("##pfMasterList", ImVec2(masterW, std::max(48.0f, bodyH - addH - Layout::spacingSm())), false);
{
ImDrawList* mdl = ImGui::GetWindowDrawList();
if (entries.empty())
// Only list groups scoped to the active wallet (or legacy/unscoped) — same filter the
// Market summary uses. `i` stays the storage index (selection/delete operate on storage).
const std::string activeHash = app->activeWalletIdentityHash();
std::vector<int> vis;
for (int i = 0; i < (int)entries.size(); i++)
if (entries[i].scope.empty() || activeHash.empty() || entries[i].scope == activeHash)
vis.push_back(i);
if (vis.empty())
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries"));
float rowH = 46.0f * dp;
float delSlot = 36.0f * dp; // reserved trailing space for the delete icon + margin
int clickedSel = -999, delRow = -1; // deferred: don't mutate `entries` mid-loop
for (int i = 0; i < (int)entries.size(); i++) {
for (int vi = 0; vi < (int)vis.size(); vi++) {
int i = vis[vi];
ImGui::PushID(i);
const auto& en = entries[i];
bool selRow = (s_pfEdit.sel == i);
@@ -1824,8 +1858,9 @@ static void mktDrawPortfolio(const MktCtx& cx)
ImGui::SameLine();
material::RightAlignX(mBtnW);
if (material::TactileButton(ml, ImVec2(mBtnW, 0))) {
// Open the combined editor selecting the first group (or the empty state if none).
PortfolioBeginEdit(app, app->settings()->getPortfolioEntries().empty() ? -1 : 0);
// Open the editor on the first group visible to this wallet (or the empty state if none)
// never a raw index 0 that might belong to a different wallet.
PortfolioBeginEdit(app, pfFirstVisibleIndex(app));
s_pfEdit.open = true;
}
}