Merge B5: sample-remove verb — this-bank scope, confirm-on-last-reference
This commit is contained in:
@@ -420,6 +420,7 @@ constexpr const char* kIdBankActivateNext = "CEREBELLUM_REASAMPLER_BANK_ACTIVATE
|
|||||||
constexpr const char* kIdBankActivatePool = "CEREBELLUM_REASAMPLER_BANK_ACTIVATE_POOL";
|
constexpr const char* kIdBankActivatePool = "CEREBELLUM_REASAMPLER_BANK_ACTIVATE_POOL";
|
||||||
constexpr const char* kIdBankMoveSel = "CEREBELLUM_REASAMPLER_BANK_MOVE_SELECTED";
|
constexpr const char* kIdBankMoveSel = "CEREBELLUM_REASAMPLER_BANK_MOVE_SELECTED";
|
||||||
constexpr const char* kIdBankCopySel = "CEREBELLUM_REASAMPLER_BANK_COPY_SELECTED";
|
constexpr const char* kIdBankCopySel = "CEREBELLUM_REASAMPLER_BANK_COPY_SELECTED";
|
||||||
|
constexpr const char* kIdBankRemoveSel = "CEREBELLUM_REASAMPLER_BANK_REMOVE_SELECTED";
|
||||||
constexpr const char* kIdBankPoolFull = "CEREBELLUM_REASAMPLER_BANK_POOL_FULLHEIGHT";
|
constexpr const char* kIdBankPoolFull = "CEREBELLUM_REASAMPLER_BANK_POOL_FULLHEIGHT";
|
||||||
constexpr const char* kIdBankBanksFull = "CEREBELLUM_REASAMPLER_BANK_BANKS_FULLHEIGHT";
|
constexpr const char* kIdBankBanksFull = "CEREBELLUM_REASAMPLER_BANK_BANKS_FULLHEIGHT";
|
||||||
|
|
||||||
@@ -431,6 +432,7 @@ int g_cmdBankActivateNext = 0;
|
|||||||
int g_cmdBankActivatePool = 0;
|
int g_cmdBankActivatePool = 0;
|
||||||
int g_cmdBankMoveSel = 0;
|
int g_cmdBankMoveSel = 0;
|
||||||
int g_cmdBankCopySel = 0;
|
int g_cmdBankCopySel = 0;
|
||||||
|
int g_cmdBankRemoveSel = 0;
|
||||||
int g_cmdBankPoolFull = 0;
|
int g_cmdBankPoolFull = 0;
|
||||||
int g_cmdBankBanksFull = 0;
|
int g_cmdBankBanksFull = 0;
|
||||||
|
|
||||||
@@ -442,6 +444,7 @@ gaccel_register_t g_accelBankActivateNext{};
|
|||||||
gaccel_register_t g_accelBankActivatePool{};
|
gaccel_register_t g_accelBankActivatePool{};
|
||||||
gaccel_register_t g_accelBankMoveSel{};
|
gaccel_register_t g_accelBankMoveSel{};
|
||||||
gaccel_register_t g_accelBankCopySel{};
|
gaccel_register_t g_accelBankCopySel{};
|
||||||
|
gaccel_register_t g_accelBankRemoveSel{};
|
||||||
gaccel_register_t g_accelBankPoolFull{};
|
gaccel_register_t g_accelBankPoolFull{};
|
||||||
gaccel_register_t g_accelBankBanksFull{};
|
gaccel_register_t g_accelBankBanksFull{};
|
||||||
|
|
||||||
@@ -747,6 +750,81 @@ void doBankTransferSelected(bool copy) {
|
|||||||
ShowConsoleMsg(log.c_str());
|
ShowConsoleMsg(log.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the panel's selected samples from the SOURCE bank (the focused region's
|
||||||
|
// displayed bank — bankPanelSelectedSourceBankId, same source as move/copy). Index-only
|
||||||
|
// and non-destructive to the file: a last-reference remove leaves the file on disk,
|
||||||
|
// orphaned until Phase R prune (remove NEVER deletes bytes — the manifest is untouched).
|
||||||
|
//
|
||||||
|
// SCOPE (fork R-A): this-bank only — the sole surfaced verb. The RemoveScope::AllBanks
|
||||||
|
// seam stays latent in the model; nothing here reaches for it.
|
||||||
|
//
|
||||||
|
// CONFIRM-ON-LAST-REFERENCE (guardrail): a remove that would orphan a file (no OTHER
|
||||||
|
// bank references its content hash after the remove) earns a confirm; a remove of a
|
||||||
|
// still-referenced sample does not. BATCH UX: for a multi-select we compute the
|
||||||
|
// last-reference set BEFORE mutating (removal changes the reference graph), then fire a
|
||||||
|
// SINGLE confirm summarizing the N that would orphan — not one dialog per sample. If
|
||||||
|
// none would orphan, no confirm fires at all (the confirm is earned by actual risk).
|
||||||
|
void doBankRemoveSelected() {
|
||||||
|
const std::vector<std::string> selected = bankPanelSelectedSampleIds();
|
||||||
|
if (selected.empty()) {
|
||||||
|
ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to remove.\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const std::string srcId = bankPanelSelectedSourceBankId();
|
||||||
|
BankBook& book = g_session->book();
|
||||||
|
const Bank* src = book.bank(srcId);
|
||||||
|
if (src == nullptr) {
|
||||||
|
ShowConsoleMsg("ReaSampler: the selection's bank no longer exists.\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count the samples whose file this remove would orphan — computed on the CURRENT
|
||||||
|
// (pre-mutation) reference graph so a same-hash sibling in another bank counts as a
|
||||||
|
// surviving reference. Resolve by id against the live source index (ids, not cached
|
||||||
|
// refs); an id no longer present is skipped (it removes to a no-op below).
|
||||||
|
int orphanCount = 0;
|
||||||
|
for (const std::string& sampleId : selected) {
|
||||||
|
const Sample* s = src->index.query(sampleId);
|
||||||
|
if (s == nullptr) continue; // already gone; not a last-reference orphan
|
||||||
|
if (!book.hashReferencedElsewhere(s->contentHash, srcId)) ++orphanCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orphanCount > 0) {
|
||||||
|
const std::string msg =
|
||||||
|
std::to_string(orphanCount) +
|
||||||
|
(orphanCount == 1 ? " selected sample is" : " selected samples are") +
|
||||||
|
" in no other bank.\n\nRemoving " +
|
||||||
|
(orphanCount == 1 ? "it" : "them") +
|
||||||
|
" drops the index entry only — the file stays on disk until you prune "
|
||||||
|
"(it is never deleted by remove).\n\nRemove anyway?";
|
||||||
|
const int r = ShowMessageBox(msg.c_str(),
|
||||||
|
"ReaSampler: remove last-reference sample(s)", 4);
|
||||||
|
if (r != 6) return; // 6 == YES; anything else cancels (SDK ~6544)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the removes (this-bank scope). Pass ids by value — no BankIndex& is cached
|
||||||
|
// across the loop's mutations. Count real drops so the no-op guardrail can skip the
|
||||||
|
// undo point when nothing was removed (every id was already absent).
|
||||||
|
int removed = 0, absent = 0;
|
||||||
|
for (const std::string& sampleId : selected) {
|
||||||
|
switch (book.removeSample(sampleId, srcId, RemoveScope::ThisBank)) {
|
||||||
|
case RemoveResult::Removed: ++removed; break;
|
||||||
|
case RemoveResult::RejectedSampleAbsent: ++absent; break;
|
||||||
|
// Unknown bank cannot occur — srcId was resolved to a live bank above.
|
||||||
|
case RemoveResult::RejectedUnknownBank: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op guardrail (R-B): open an undo point only if the index actually mutated.
|
||||||
|
if (removed > 0) persistBankOp("ReaSampler: remove sample(s)");
|
||||||
|
|
||||||
|
std::string log = "ReaSampler: removed " + std::to_string(removed) +
|
||||||
|
(removed == 1 ? " sample" : " samples");
|
||||||
|
if (absent) log += ", " + std::to_string(absent) + " no longer present";
|
||||||
|
log += ".\n";
|
||||||
|
ShowConsoleMsg(log.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
|
void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
|
||||||
@@ -768,6 +846,8 @@ void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session)
|
|||||||
"ReaSampler: move selected samples to bank");
|
"ReaSampler: move selected samples to bank");
|
||||||
g_cmdBankCopySel = registerAction(rec, kIdBankCopySel, g_accelBankCopySel,
|
g_cmdBankCopySel = registerAction(rec, kIdBankCopySel, g_accelBankCopySel,
|
||||||
"ReaSampler: copy selected samples to bank");
|
"ReaSampler: copy selected samples to bank");
|
||||||
|
g_cmdBankRemoveSel = registerAction(rec, kIdBankRemoveSel, g_accelBankRemoveSel,
|
||||||
|
"ReaSampler: remove selected samples");
|
||||||
g_cmdBankPoolFull = registerAction(rec, kIdBankPoolFull, g_accelBankPoolFull,
|
g_cmdBankPoolFull = registerAction(rec, kIdBankPoolFull, g_accelBankPoolFull,
|
||||||
"ReaSampler: toggle pool full-height");
|
"ReaSampler: toggle pool full-height");
|
||||||
g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull,
|
g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull,
|
||||||
@@ -785,6 +865,7 @@ bool bankHandleCommand(int command) {
|
|||||||
if (command == g_cmdBankActivatePool) { doBankActivatePool(); return true; }
|
if (command == g_cmdBankActivatePool) { doBankActivatePool(); return true; }
|
||||||
if (command == g_cmdBankMoveSel) { doBankTransferSelected(false); return true; }
|
if (command == g_cmdBankMoveSel) { doBankTransferSelected(false); return true; }
|
||||||
if (command == g_cmdBankCopySel) { doBankTransferSelected(true); return true; }
|
if (command == g_cmdBankCopySel) { doBankTransferSelected(true); return true; }
|
||||||
|
if (command == g_cmdBankRemoveSel) { doBankRemoveSelected(); return true; }
|
||||||
if (command == g_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); return true; }
|
if (command == g_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); return true; }
|
||||||
if (command == g_cmdBankBanksFull) { bankPanelToggledBanksFullHeight(); return true; }
|
if (command == g_cmdBankBanksFull) { bankPanelToggledBanksFullHeight(); return true; }
|
||||||
|
|
||||||
@@ -797,6 +878,8 @@ void bankUnregisterActions(reaper_plugin_info_t* rec) {
|
|||||||
rec->Register("-command_id", (void*)kIdBankBanksFull);
|
rec->Register("-command_id", (void*)kIdBankBanksFull);
|
||||||
rec->Register("-gaccel", (void*)&g_accelBankPoolFull);
|
rec->Register("-gaccel", (void*)&g_accelBankPoolFull);
|
||||||
rec->Register("-command_id", (void*)kIdBankPoolFull);
|
rec->Register("-command_id", (void*)kIdBankPoolFull);
|
||||||
|
rec->Register("-gaccel", (void*)&g_accelBankRemoveSel);
|
||||||
|
rec->Register("-command_id", (void*)kIdBankRemoveSel);
|
||||||
rec->Register("-gaccel", (void*)&g_accelBankCopySel);
|
rec->Register("-gaccel", (void*)&g_accelBankCopySel);
|
||||||
rec->Register("-command_id", (void*)kIdBankCopySel);
|
rec->Register("-command_id", (void*)kIdBankCopySel);
|
||||||
rec->Register("-gaccel", (void*)&g_accelBankMoveSel);
|
rec->Register("-gaccel", (void*)&g_accelBankMoveSel);
|
||||||
|
|||||||
@@ -281,6 +281,39 @@ TransferResult BankBook::copySample(const std::string& sampleId,
|
|||||||
return applyDestAdd(to->index, copy, TransferResult::Copied);
|
return applyDestAdd(to->index, copy, TransferResult::Copied);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sample removal (index-only) + the last-reference query
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
RemoveResult BankBook::removeSample(const std::string& sampleId,
|
||||||
|
const std::string& fromBankId,
|
||||||
|
RemoveScope scope) {
|
||||||
|
if (scope == RemoveScope::AllBanks) {
|
||||||
|
// Latent seam: purge the id from every bank that holds it. fromBankId is
|
||||||
|
// ignored (the id is dropped book-wide). Removed iff at least one drop landed.
|
||||||
|
bool any = false;
|
||||||
|
for (auto& b : banks_)
|
||||||
|
if (b.index.remove(sampleId)) any = true;
|
||||||
|
return any ? RemoveResult::Removed : RemoveResult::RejectedSampleAbsent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThisBank (default, the only surfaced verb): drop from the one named source bank.
|
||||||
|
Bank* from = bank(fromBankId);
|
||||||
|
if (from == nullptr) return RemoveResult::RejectedUnknownBank;
|
||||||
|
return from->index.remove(sampleId) ? RemoveResult::Removed
|
||||||
|
: RemoveResult::RejectedSampleAbsent;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BankBook::hashReferencedElsewhere(const std::string& hash,
|
||||||
|
const std::string& exceptBankId) const {
|
||||||
|
if (hash.empty()) return false; // empty hashes never dedup (mirror findByHash)
|
||||||
|
for (const auto& b : banks_) {
|
||||||
|
if (b.id == exceptBankId) continue; // the removed-from bank is excluded
|
||||||
|
if (b.index.findByHash(hash) != nullptr) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// JSON — writer
|
// JSON — writer
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
@@ -85,6 +85,30 @@ enum class TransferResult {
|
|||||||
RejectedSameBank,
|
RejectedSameBank,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Scope of a sample-remove (fork R-A, settled 2026-07-24). ThisBank is the default
|
||||||
|
// and the ONLY behavior surfaced in the UI/action layer; AllBanks is a latent seam —
|
||||||
|
// live and tested at the model level, promotable later behind this parameter without
|
||||||
|
// a rewrite, but never wired to an affordance in B5.
|
||||||
|
// - ThisBank: drop the entry from the one named source bank only. A same-hash entry
|
||||||
|
// in another bank survives (no cross-bank cascade — dedup is per-bank).
|
||||||
|
// - AllBanks: drop the sample's entry from EVERY bank that holds the source id
|
||||||
|
// ("purge from the library"). Latent; unsurfaced.
|
||||||
|
enum class RemoveScope {
|
||||||
|
ThisBank,
|
||||||
|
AllBanks,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Outcome of BankBook::removeSample. Mirrors TransferResult's honesty: the op reports
|
||||||
|
// what happened rather than silently mutating on a bad request.
|
||||||
|
// - Removed: at least one index entry was dropped.
|
||||||
|
// - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only).
|
||||||
|
// - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed).
|
||||||
|
enum class RemoveResult {
|
||||||
|
Removed,
|
||||||
|
RejectedUnknownBank,
|
||||||
|
RejectedSampleAbsent,
|
||||||
|
};
|
||||||
|
|
||||||
// An ordered registry of banks with the pool seeded as bank-zero, per-bank sample
|
// An ordered registry of banks with the pool seeded as bank-zero, per-bank sample
|
||||||
// indices, an active-bank pointer, and lossless JSON round-trip. The heart of the
|
// indices, an active-bank pointer, and lossless JSON round-trip. The heart of the
|
||||||
// multi-bank phase — mirror of bank_model / view_mode_model.
|
// multi-bank phase — mirror of bank_model / view_mode_model.
|
||||||
@@ -155,6 +179,37 @@ public:
|
|||||||
const std::string& fromBankId,
|
const std::string& fromBankId,
|
||||||
const std::string& toBankId);
|
const std::string& toBankId);
|
||||||
|
|
||||||
|
// -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) --
|
||||||
|
|
||||||
|
// Drops a sample's index entry (the sample-level sibling of move/copy/evacuate).
|
||||||
|
// Index-only and non-destructive to the file: a last-reference remove leaves the
|
||||||
|
// file on disk, orphaned until Phase R prune — remove NEVER deletes bytes.
|
||||||
|
//
|
||||||
|
// Scope (fork R-A): ThisBank (default, the only surfaced verb) drops the entry from
|
||||||
|
// `fromBankId` alone; AllBanks (latent seam) drops the sample id from every bank
|
||||||
|
// that holds it. See RemoveResult for the outcome set.
|
||||||
|
// * ThisBank: RejectedUnknownBank if `fromBankId` names no bank; RejectedSampleAbsent
|
||||||
|
// if that bank does not hold the id; Removed on a drop.
|
||||||
|
// * AllBanks: `fromBankId` is ignored (the id is purged book-wide);
|
||||||
|
// RejectedSampleAbsent if NO bank held the id; Removed otherwise.
|
||||||
|
// No mutation occurs on any Rejected outcome (no-op guardrail for the undo layer).
|
||||||
|
RemoveResult removeSample(const std::string& sampleId,
|
||||||
|
const std::string& fromBankId,
|
||||||
|
RemoveScope scope = RemoveScope::ThisBank);
|
||||||
|
|
||||||
|
// Reference-count query backing the confirm-on-last-reference guardrail: does any
|
||||||
|
// bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`?
|
||||||
|
//
|
||||||
|
// Identity is the CONTENT HASH, not the file path: hash is the canonical dedup key
|
||||||
|
// the whole model already reasons in (findByHash / collapse-by-hash), and two
|
||||||
|
// entries that share content share one file — so "some other bank still references
|
||||||
|
// this hash" is exactly "removing here does not orphan the file." An EMPTY hash is
|
||||||
|
// never matched (it does not participate in dedup, mirroring findByHash), so an
|
||||||
|
// empty-hash sample reads as referenced-nowhere-else — the safe, confirm-eliciting
|
||||||
|
// direction (we cannot prove another bank shares an unhashed file).
|
||||||
|
bool hashReferencedElsewhere(const std::string& hash,
|
||||||
|
const std::string& exceptBankId) const;
|
||||||
|
|
||||||
// -- Query ---------------------------------------------------------------
|
// -- Query ---------------------------------------------------------------
|
||||||
|
|
||||||
// The bank with `id`, or nullptr. Pointer invalidated by any mutating call.
|
// The bank with `id`, or nullptr. Pointer invalidated by any mutating call.
|
||||||
|
|||||||
+69
-9
@@ -1315,6 +1315,55 @@ void transferSamples(const std::vector<std::string>& sampleIds,
|
|||||||
invalidatePanel();
|
invalidatePanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Non-destructive to
|
||||||
|
// the file: a last-reference remove leaves the file on disk, orphaned until Phase R
|
||||||
|
// prune — remove NEVER deletes bytes (the manifest is untouched). Confirm-on-last-
|
||||||
|
// reference guardrail: a single confirm summarizing the N whose files this would orphan
|
||||||
|
// (computed on the PRE-mutation reference graph), fired only when at least one would
|
||||||
|
// orphan. Ids passed by value — no BankIndex& cached across the loop's mutations.
|
||||||
|
void removeSamples(const std::vector<std::string>& sampleIds,
|
||||||
|
const std::string& srcBankId) {
|
||||||
|
if (!book() || sampleIds.empty()) return;
|
||||||
|
const Bank* src = book()->bank(srcBankId);
|
||||||
|
if (!src) return;
|
||||||
|
|
||||||
|
// Count files this remove would orphan — computed BEFORE mutating, so a same-hash
|
||||||
|
// sibling in another bank counts as a surviving reference.
|
||||||
|
int orphanCount = 0;
|
||||||
|
for (const std::string& sid : sampleIds) {
|
||||||
|
const Sample* s = src->index.query(sid);
|
||||||
|
if (!s) continue; // already gone; not a last-reference orphan
|
||||||
|
if (!book()->hashReferencedElsewhere(s->contentHash, srcBankId)) ++orphanCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orphanCount > 0) {
|
||||||
|
const std::string msg =
|
||||||
|
std::to_string(orphanCount) +
|
||||||
|
(orphanCount == 1 ? " selected sample is" : " selected samples are") +
|
||||||
|
" in no other bank.\n\nRemoving " +
|
||||||
|
(orphanCount == 1 ? "it" : "them") +
|
||||||
|
" drops the index entry only — the file stays on disk until you prune "
|
||||||
|
"(it is never deleted by remove).\n\nRemove anyway?";
|
||||||
|
// 4 == MB_YESNO. 6=Yes (SDK); anything else cancels.
|
||||||
|
const int r = ShowMessageBox(msg.c_str(),
|
||||||
|
"ReaSampler: remove last-reference sample(s)", 4);
|
||||||
|
if (r != 6) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int removed = 0;
|
||||||
|
for (const std::string& sid : sampleIds)
|
||||||
|
if (book()->removeSample(sid, srcBankId, RemoveScope::ThisBank) ==
|
||||||
|
RemoveResult::Removed)
|
||||||
|
++removed;
|
||||||
|
if (removed == 0) return; // nothing changed — no persist, no undo point
|
||||||
|
|
||||||
|
persistBook();
|
||||||
|
// The selection indexed into the source; after a remove those indices are stale, so
|
||||||
|
// clear it (the fingerprint pass will also clear, but do it now for immediacy).
|
||||||
|
g_panel.selection = Selection{};
|
||||||
|
invalidatePanel();
|
||||||
|
}
|
||||||
|
|
||||||
// The selection's sample ids resolved against the FOCUSED region's bank (source of a
|
// The selection's sample ids resolved against the FOCUSED region's bank (source of a
|
||||||
// move/copy). Returns ids in bank order; empty when nothing selected.
|
// move/copy). Returns ids in bank order; empty when nothing selected.
|
||||||
std::vector<std::string> focusedSelectionIds() {
|
std::vector<std::string> focusedSelectionIds() {
|
||||||
@@ -1356,6 +1405,7 @@ enum : unsigned int {
|
|||||||
kMenuDelete,
|
kMenuDelete,
|
||||||
kMenuEvacuate,
|
kMenuEvacuate,
|
||||||
kMenuCreate,
|
kMenuCreate,
|
||||||
|
kMenuRemove, // remove selected sample(s) from the source bank (B5)
|
||||||
kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index
|
kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index
|
||||||
kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index
|
kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index
|
||||||
};
|
};
|
||||||
@@ -1411,16 +1461,13 @@ void showSelectionMenu(int screenX, int screenY) {
|
|||||||
for (const Bank* bk : namedBanks())
|
for (const Bank* bk : namedBanks())
|
||||||
if (bk->id != srcId) dests.push_back({bk->id, bk->displayName});
|
if (bk->id != srcId) dests.push_back({bk->id, bk->displayName});
|
||||||
|
|
||||||
HMENU menu = CreatePopupMenu();
|
|
||||||
if (dests.empty()) {
|
|
||||||
menuAppend(menu, kMenuNone, "No other bank to move to", /*grayed=*/true);
|
|
||||||
TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, g_panel.hwnd, nullptr);
|
|
||||||
DestroyMenu(menu);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const std::string label = std::to_string(sel.size()) +
|
const std::string label = std::to_string(sel.size()) +
|
||||||
(sel.size() == 1 ? " sample" : " samples");
|
(sel.size() == 1 ? " sample" : " samples");
|
||||||
|
|
||||||
|
HMENU menu = CreatePopupMenu();
|
||||||
|
// Move/copy blocks appear only when there is another bank to transfer to; Remove is
|
||||||
|
// always offered (it needs no destination — it drops the entry from the source).
|
||||||
|
if (!dests.empty()) {
|
||||||
menuAppend(menu, kMenuNone, ("Move " + label + " to:").c_str(), /*grayed=*/true);
|
menuAppend(menu, kMenuNone, ("Move " + label + " to:").c_str(), /*grayed=*/true);
|
||||||
for (std::size_t i = 0; i < dests.size(); ++i)
|
for (std::size_t i = 0; i < dests.size(); ++i)
|
||||||
menuAppend(menu, kMenuMoveBase + static_cast<unsigned int>(i),
|
menuAppend(menu, kMenuMoveBase + static_cast<unsigned int>(i),
|
||||||
@@ -1430,11 +1477,16 @@ void showSelectionMenu(int screenX, int screenY) {
|
|||||||
for (std::size_t i = 0; i < dests.size(); ++i)
|
for (std::size_t i = 0; i < dests.size(); ++i)
|
||||||
menuAppend(menu, kMenuCopyBase + static_cast<unsigned int>(i),
|
menuAppend(menu, kMenuCopyBase + static_cast<unsigned int>(i),
|
||||||
(" " + dests[i].name).c_str());
|
(" " + dests[i].name).c_str());
|
||||||
|
menuSeparator(menu);
|
||||||
|
}
|
||||||
|
menuAppend(menu, kMenuRemove, ("Remove " + label + "\xE2\x80\xA6").c_str());
|
||||||
|
|
||||||
const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0,
|
const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0,
|
||||||
g_panel.hwnd, nullptr);
|
g_panel.hwnd, nullptr);
|
||||||
DestroyMenu(menu);
|
DestroyMenu(menu);
|
||||||
if (cmd >= static_cast<int>(kMenuMoveBase) &&
|
if (cmd == static_cast<int>(kMenuRemove)) {
|
||||||
|
removeSamples(sel, srcId);
|
||||||
|
} else if (cmd >= static_cast<int>(kMenuMoveBase) &&
|
||||||
cmd < static_cast<int>(kMenuMoveBase + dests.size())) {
|
cmd < static_cast<int>(kMenuMoveBase + dests.size())) {
|
||||||
transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false);
|
transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false);
|
||||||
} else if (cmd >= static_cast<int>(kMenuCopyBase) &&
|
} else if (cmd >= static_cast<int>(kMenuCopyBase) &&
|
||||||
@@ -1670,6 +1722,14 @@ bool handleKey(int vk) {
|
|||||||
case VK_ESCAPE:
|
case VK_ESCAPE:
|
||||||
stopAudition();
|
stopAudition();
|
||||||
return true;
|
return true;
|
||||||
|
case VK_DELETE: {
|
||||||
|
// Remove the focused-region selection (B5). Same confirm-on-last-reference
|
||||||
|
// path the context-menu "Remove" uses; a no-op when nothing is selected.
|
||||||
|
const std::vector<std::string> sel = focusedSelectionIds();
|
||||||
|
if (sel.empty()) return false; // nothing selected — let the key fall through
|
||||||
|
removeSamples(sel, bankIdForRegion(g_panel.focusedRegion));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -638,6 +638,124 @@ static void testDeserializeNamedBankCollidingWithPoolIsDisambiguated() {
|
|||||||
if (back2) CHECK(back2->serialize() == back->serialize());
|
if (back2) CHECK(back2->serialize() == back->serialize());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- B5: sample-remove (this-bank + latent all-banks) + last-reference query ------
|
||||||
|
//
|
||||||
|
// removeSample drops a Sample's index entry (index-only, non-destructive to the file).
|
||||||
|
// ThisBank (default, surfaced) drops from one named source bank; AllBanks (latent seam)
|
||||||
|
// purges the id book-wide. hashReferencedElsewhere backs the confirm-on-last-reference
|
||||||
|
// guardrail: does any OTHER bank still hold the content hash?
|
||||||
|
|
||||||
|
static void testRemoveDropsTargetEntry() {
|
||||||
|
BankBook book;
|
||||||
|
CHECK(book.createBank("drums", "Drums"));
|
||||||
|
CHECK(book.bank("drums")->index.add(sampleWith("kick")) == AddResult::Added);
|
||||||
|
CHECK(book.bank("drums")->index.add(sampleWith("snare")) == AddResult::Added);
|
||||||
|
|
||||||
|
// Remove the kick from drums: dropped from the target bank, the sibling survives.
|
||||||
|
CHECK(book.removeSample("id-kick", "drums") == RemoveResult::Removed);
|
||||||
|
CHECK(book.bank("drums")->index.query("id-kick") == nullptr); // dropped
|
||||||
|
CHECK(book.bank("drums")->index.query("id-snare") != nullptr); // sibling kept
|
||||||
|
CHECK(book.bank("drums")->index.size() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRemoveThisBankLeavesSameHashInAnotherBank() {
|
||||||
|
// Copy a sample into two banks (same hash in both), then remove from one under the
|
||||||
|
// default this-bank scope: the OTHER bank's entry survives — no cross-bank cascade.
|
||||||
|
BankBook book;
|
||||||
|
CHECK(book.createBank("drums", "Drums"));
|
||||||
|
CHECK(book.pool().index.add(sampleWith("clap", "clap-hash")) == AddResult::Added);
|
||||||
|
CHECK(book.copySample("id-clap", kPoolBankId, "drums") == TransferResult::Copied);
|
||||||
|
|
||||||
|
CHECK(book.removeSample("id-clap", kPoolBankId, RemoveScope::ThisBank) ==
|
||||||
|
RemoveResult::Removed);
|
||||||
|
CHECK(book.pool().index.query("id-clap") == nullptr); // removed from pool
|
||||||
|
CHECK(book.bank("drums")->index.query("id-clap") != nullptr); // drums copy survives
|
||||||
|
CHECK(book.bank("drums")->index.findByHash("clap-hash") != nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRemoveFromPoolAllowedContainerPrivilegesHold() {
|
||||||
|
// Pool CONTENTS are removable (the pool must not be a roach-motel); the pool
|
||||||
|
// CONTAINER privileges (un-deletable / un-renamable / un-evacuable) are untouched.
|
||||||
|
BankBook book;
|
||||||
|
CHECK(book.pool().index.add(sampleWith("loop")) == AddResult::Added);
|
||||||
|
|
||||||
|
CHECK(book.removeSample("id-loop", kPoolBankId) == RemoveResult::Removed);
|
||||||
|
CHECK(book.pool().index.empty()); // content removed
|
||||||
|
|
||||||
|
// Container privileges still enforced.
|
||||||
|
CHECK(!book.deleteBank(kPoolBankId));
|
||||||
|
CHECK(!book.renameBank(kPoolBankId, "NotPool"));
|
||||||
|
CHECK(!book.evacuate(kPoolBankId));
|
||||||
|
CHECK(book.size() == 1);
|
||||||
|
CHECK(book.pool().displayName == std::string(kPoolBankName));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRemoveRejectionsNoMutation() {
|
||||||
|
BankBook book;
|
||||||
|
CHECK(book.createBank("drums", "Drums"));
|
||||||
|
CHECK(book.bank("drums")->index.add(sampleWith("kick")) == AddResult::Added);
|
||||||
|
|
||||||
|
// Unknown bank → honest rejection, no mutation.
|
||||||
|
CHECK(book.removeSample("id-kick", "ghost") == RemoveResult::RejectedUnknownBank);
|
||||||
|
CHECK(book.bank("drums")->index.query("id-kick") != nullptr); // untouched
|
||||||
|
|
||||||
|
// Absent sample (right bank, wrong id) → honest rejection, no mutation.
|
||||||
|
CHECK(book.removeSample("id-missing", "drums") == RemoveResult::RejectedSampleAbsent);
|
||||||
|
CHECK(book.bank("drums")->index.size() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testHashReferencedElsewhere() {
|
||||||
|
BankBook book;
|
||||||
|
CHECK(book.createBank("drums", "Drums"));
|
||||||
|
CHECK(book.createBank("hits", "Hits"));
|
||||||
|
|
||||||
|
// Same-bank-only: the hash lives ONLY in drums → not referenced elsewhere (true
|
||||||
|
// last-reference; removing from drums would orphan the file).
|
||||||
|
CHECK(book.bank("drums")->index.add(sampleWith("solo", "solo-hash")) == AddResult::Added);
|
||||||
|
CHECK(!book.hashReferencedElsewhere("solo-hash", "drums"));
|
||||||
|
|
||||||
|
// Copied-to-two-banks: the hash lives in drums AND hits → referenced elsewhere from
|
||||||
|
// either vantage (removing from one leaves the other's reference intact).
|
||||||
|
CHECK(book.bank("drums")->index.add(sampleWith("dup-d", "dup-hash")) == AddResult::Added);
|
||||||
|
CHECK(book.bank("hits")->index.add(sampleWith("dup-h", "dup-hash")) == AddResult::Added);
|
||||||
|
CHECK(book.hashReferencedElsewhere("dup-hash", "drums")); // hits still holds it
|
||||||
|
CHECK(book.hashReferencedElsewhere("dup-hash", "hits")); // drums still holds it
|
||||||
|
|
||||||
|
// A hash present in NO bank is referenced nowhere.
|
||||||
|
CHECK(!book.hashReferencedElsewhere("absent-hash", "drums"));
|
||||||
|
|
||||||
|
// An empty hash never matches (mirrors findByHash) → reads as not-referenced-else,
|
||||||
|
// the safe confirm-eliciting direction for an unhashed sample.
|
||||||
|
CHECK(book.pool().index.add(sampleWith("nohash", "")) == AddResult::Added);
|
||||||
|
CHECK(!book.hashReferencedElsewhere("", "drums"));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRemoveAllBanksLatentScope() {
|
||||||
|
// The latent all-banks seam (fork R-A): unsurfaced in the UI but live at the model
|
||||||
|
// level. Purges the id from EVERY bank that holds it in one act; fromBankId ignored.
|
||||||
|
BankBook book;
|
||||||
|
CHECK(book.createBank("drums", "Drums"));
|
||||||
|
CHECK(book.createBank("hits", "Hits"));
|
||||||
|
CHECK(book.pool().index.add(sampleWith("clap", "clap-hash")) == AddResult::Added);
|
||||||
|
CHECK(book.copySample("id-clap", kPoolBankId, "drums") == TransferResult::Copied);
|
||||||
|
CHECK(book.copySample("id-clap", kPoolBankId, "hits") == TransferResult::Copied);
|
||||||
|
// The id now lives in all three banks.
|
||||||
|
CHECK(book.pool().index.query("id-clap") != nullptr);
|
||||||
|
CHECK(book.bank("drums")->index.query("id-clap") != nullptr);
|
||||||
|
CHECK(book.bank("hits")->index.query("id-clap") != nullptr);
|
||||||
|
|
||||||
|
// AllBanks purge — fromBankId is ignored (pass a nonexistent bank to prove it).
|
||||||
|
CHECK(book.removeSample("id-clap", "ignored-bank", RemoveScope::AllBanks) ==
|
||||||
|
RemoveResult::Removed);
|
||||||
|
CHECK(book.pool().index.query("id-clap") == nullptr);
|
||||||
|
CHECK(book.bank("drums")->index.query("id-clap") == nullptr);
|
||||||
|
CHECK(book.bank("hits")->index.query("id-clap") == nullptr);
|
||||||
|
|
||||||
|
// A second all-banks purge of the now-absent id is an honest no-op rejection.
|
||||||
|
CHECK(book.removeSample("id-clap", "ignored-bank", RemoveScope::AllBanks) ==
|
||||||
|
RemoveResult::RejectedSampleAbsent);
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testPoolSeededAndDefaults();
|
testPoolSeededAndDefaults();
|
||||||
testPoolPrivileges();
|
testPoolPrivileges();
|
||||||
@@ -667,6 +785,12 @@ int main() {
|
|||||||
testDeserializeCoalescesDuplicateFoldedNames();
|
testDeserializeCoalescesDuplicateFoldedNames();
|
||||||
testDeserializeCoalescesMultipleCollisions();
|
testDeserializeCoalescesMultipleCollisions();
|
||||||
testDeserializeNamedBankCollidingWithPoolIsDisambiguated();
|
testDeserializeNamedBankCollidingWithPoolIsDisambiguated();
|
||||||
|
testRemoveDropsTargetEntry();
|
||||||
|
testRemoveThisBankLeavesSameHashInAnotherBank();
|
||||||
|
testRemoveFromPoolAllowedContainerPrivilegesHold();
|
||||||
|
testRemoveRejectionsNoMutation();
|
||||||
|
testHashReferencedElsewhere();
|
||||||
|
testRemoveAllBanksLatentScope();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||||
return g_fail ? 1 : 0;
|
return g_fail ? 1 : 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user