feat(prune): R3 guarded deletion — confirm-to-delete + panel button
Extend BANK_PRUNE_FOLDER from report-only to dry-run→confirm-with-manifest→ delete exactly the pure-core orphan set (fresh recompute, stale entries skipped). Windows routes to Recycle Bin (SHFileOperation+FOF_ALLOWUNDO), else unlink. New pure prune_button module + footer button dispatching the action id. No ext-state write, no undo point (deletion is not REAPER-undoable).
This commit is contained in:
+49
-11
@@ -797,29 +797,65 @@ void doBankRemoveSelected() {
|
||||
if (removed > 0) persistBankOp("ReaSampler: remove sample(s)");
|
||||
}
|
||||
|
||||
// Prune bank folder — Phase R (Reclaim), R2: REPORT-ONLY dry run. Asks the session for
|
||||
// the orphan set of the resolved CURRENT bank folder ((owned ∩ present) − referenced,
|
||||
// unioned across every bank) and prints the truthful reclaim report — count, reclaimable
|
||||
// bytes, and the file list. DELETES NOTHING, writes no ext-state, opens no undo point
|
||||
// (pruneDryRun is read-only across the persist seam). R3 extends this SAME action id to
|
||||
// confirm-and-delete behind the dry-run guardrail; the report path here is what R3 wraps.
|
||||
// Prune bank folder — Phase R (Reclaim), R3: the guarded DESTRUCTIVE step, and the SOLE
|
||||
// file-deletion entry in ReaSampler. Dry-run FIRST (compute the orphan set, read-only),
|
||||
// then — only when orphans exist — a blocking CONFIRM showing the SPECIFIC manifest
|
||||
// (count + reclaimable bytes + the file list, truncated consistent with the 64-cap), then
|
||||
// on explicit Yes delete EXACTLY that set (session->pruneReclaim, which recomputes the
|
||||
// pure core fresh and deletes confirmed ∩ freshOrphans — trash-preferred, unlink fallback).
|
||||
// Zero orphans => informational only, NO confirm ever shown. Cancel deletes nothing.
|
||||
//
|
||||
// The full (untruncated) orphan set is captured here for the delete; the dry-run's
|
||||
// truncated list is only the confirm's readout. No ext-state is written and no undo point
|
||||
// is opened (file deletion is not REAPER-undoable and pruneReclaim mutates no project
|
||||
// state) — a Ctrl-Z after a prune correctly cannot claim to restore deleted files.
|
||||
void doBankPruneFolder() {
|
||||
const PruneReport report = g_session->pruneDryRun();
|
||||
|
||||
if (report.count == 0) {
|
||||
ShowConsoleMsg("ReaSampler prune (dry run): no orphaned files to reclaim.\n");
|
||||
ShowConsoleMsg("ReaSampler prune: no orphaned files to reclaim.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
std::string msg = "ReaSampler prune (dry run): " + std::to_string(report.count) +
|
||||
" orphaned file(s), " + std::to_string(report.totalBytes) +
|
||||
" bytes reclaimable. (Dry run -- nothing deleted.)\n";
|
||||
// The EXACT set the delete will target — full, untruncated, so what the confirm
|
||||
// summarises (count + bytes) matches what pruneReclaim reclaims. Captured before the
|
||||
// confirm so the confirm and the delete reason about the same enumeration.
|
||||
const std::vector<std::string> orphanSet = g_session->pruneOrphanSet();
|
||||
|
||||
// Confirm-with-manifest: count + bytes exact; the file list is the dry-run's 64-capped
|
||||
// list (the same clip the R2 readout used), with a "N more not shown" tail when clipped.
|
||||
std::string msg =
|
||||
"ReaSampler prune will PERMANENTLY reclaim " + std::to_string(report.count) +
|
||||
" orphaned file(s), freeing " + std::to_string(report.totalBytes) + " bytes.\n\n"
|
||||
"These files are no longer referenced by any bank and were created by ReaSampler.\n"
|
||||
"They will be moved to the Recycle Bin on Windows (recoverable), or deleted on "
|
||||
"other platforms.\n\n";
|
||||
for (const std::string& rel : report.orphans) msg += " " + rel + "\n";
|
||||
if (report.truncated) {
|
||||
msg += " ... (" + std::to_string(report.count - report.orphans.size()) +
|
||||
" more not shown)\n";
|
||||
}
|
||||
ShowConsoleMsg(msg.c_str());
|
||||
msg += "\nReclaim these files now?";
|
||||
|
||||
const int r = ShowMessageBox(msg.c_str(), "ReaSampler: prune bank folder", 4);
|
||||
if (r != 6) { // 6 == YES; anything else cancels -> delete NOTHING (SDK ~6544)
|
||||
ShowConsoleMsg("ReaSampler prune: cancelled -- nothing deleted.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirmed -> delete exactly the confirmed set (recomputed fresh, stale entries skipped).
|
||||
const PruneDeletionResult del = g_session->pruneReclaim(orphanSet);
|
||||
|
||||
std::string done = "ReaSampler prune: reclaimed " +
|
||||
std::to_string(del.reclaimedCount) + " file(s), " +
|
||||
std::to_string(del.reclaimedBytes) + " bytes" +
|
||||
(del.usedTrash ? " (to Recycle Bin)" : " (deleted)") + ".";
|
||||
if (del.skippedCount > 0) {
|
||||
done += " " + std::to_string(del.skippedCount) +
|
||||
" file(s) skipped (locked, or changed since the report).";
|
||||
}
|
||||
done += "\n";
|
||||
ShowConsoleMsg(done.c_str());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -907,6 +943,8 @@ bool bankHandleCommand(int command) {
|
||||
return false; // not ours — caller's hookcommand keeps looking
|
||||
}
|
||||
|
||||
int bankPruneCommandId() { return g_cmdBankPruneFolder; }
|
||||
|
||||
void bankUnregisterActions(reaper_plugin_info_t* rec) {
|
||||
// Mirror-unregister with '-'-prefixed strings, reverse of registration order. Each
|
||||
// '-command_id' re-presents the same interned channel-qualified id (channelIdFor).
|
||||
|
||||
@@ -66,6 +66,12 @@ bool bankHandleCommand(int command);
|
||||
// rec==nullptr (before g_session is torn down).
|
||||
void bankUnregisterActions(reaper_plugin_info_t* rec);
|
||||
|
||||
// The registered command id for the "Prune bank folder" action (Phase R, R3), or 0 before
|
||||
// registration. The bank_panel prune button fires the action THROUGH this id via
|
||||
// Main_OnCommand (fork R-E: the button dispatches the command, it does not call the session
|
||||
// directly) so the panel affordance and the bindable action share one guarded code path.
|
||||
int bankPruneCommandId();
|
||||
|
||||
// Persists a completed bank-index verb as a single REAPER undo point (R-B).
|
||||
// Wraps persistBook() (= SetProjExtState) in a Begin/End block with UNDO_STATE_MISCCFG
|
||||
// so the bank op is one Ctrl-Z. On an unsaved / no-active project persistBook() no-ops
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
#include "mode_switch.h"
|
||||
#include "peaks.h"
|
||||
#include "persist.h"
|
||||
#include "prune_button.h" // footer prune-button layout + hit-test (pure, R3)
|
||||
#include "tab_strip.h"
|
||||
#include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure)
|
||||
#include "track_guid.h" // guidString — canonical track GUID key (D2 Wave 2)
|
||||
@@ -102,6 +103,7 @@
|
||||
#define REAPERAPI_WANT_StopPreview
|
||||
#define REAPERAPI_WANT_GetUserInputs
|
||||
#define REAPERAPI_WANT_ShowMessageBox
|
||||
#define REAPERAPI_WANT_Main_OnCommand // fire the prune action by command id (R3 button)
|
||||
#define REAPERAPI_WANT_genGuid
|
||||
#define REAPERAPI_WANT_guidToString
|
||||
#include "reaper_plugin_functions.h"
|
||||
@@ -152,6 +154,13 @@ const COLORREF kRgbFooterText = RGB(190, 205, 198);
|
||||
// not an interactive control, so it recedes visually (V3 unobtrusive placement).
|
||||
const COLORREF kRgbFooterVersion = RGB(120, 128, 124);
|
||||
|
||||
// Prune button (R3): a raised control in the footer that fires the "Prune bank folder"
|
||||
// action. A muted warm tone so it reads as a distinct-but-not-alarming affordance (the
|
||||
// destructive confirm lives behind it, not on the button itself).
|
||||
const LICE_pixel kColPruneBtnBg = LICE_RGBA(62, 46, 42, 255);
|
||||
const LICE_pixel kColPruneBtnBorder = LICE_RGBA(96, 72, 66, 255);
|
||||
const COLORREF kRgbPruneBtnText = RGB(210, 188, 180);
|
||||
|
||||
// --- Vertical split + region headers + tab strip (Phase B4) -------------------
|
||||
//
|
||||
// The client area, top to bottom: mode-switch header (kHeaderHeight) | split body |
|
||||
@@ -554,6 +563,37 @@ void drawTailFooter(LICE_IBitmap* bmp, int w, int h) {
|
||||
DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
|
||||
}
|
||||
|
||||
// The prune button's rect within the footer, derived from the client size. SINGLE source
|
||||
// of truth for both draw and hit-test (they never drift). Empty (button.empty()) when the
|
||||
// footer is degenerate or too narrow to place the button clear of the tail label — the
|
||||
// action stays reachable via its bindable command, so a suppressed button is graceful.
|
||||
// The version readout uses an 8 px right inset (drawTailFooter); the button's rightInset
|
||||
// clears it (~72 px) so the two never overlap at normal panel widths.
|
||||
ButtonRect pruneButtonRectFor(int w, int h) {
|
||||
const RECT f = panelFooter(w, h);
|
||||
if (f.top >= f.bottom) return ButtonRect{}; // degenerate footer -> no button
|
||||
const FooterRect footer{f.left, f.top, f.right - f.left, f.bottom - f.top};
|
||||
return computePruneButton(footer, PruneButtonSpec{});
|
||||
}
|
||||
|
||||
// Draws the prune button into the footer (called after drawTailFooter fills the strip).
|
||||
// No-op when the button is suppressed (footer too narrow). READ-ONLY: draws only.
|
||||
void drawPruneButton(LICE_IBitmap* bmp, int w, int h) {
|
||||
const ButtonRect b = pruneButtonRectFor(w, h);
|
||||
if (b.empty()) return;
|
||||
|
||||
LICE_FillRect(bmp, b.x, b.y, b.width, b.height, kColPruneBtnBg, 1.0f, 0);
|
||||
LICE_DrawRect(bmp, b.x, b.y, b.width, b.height, kColPruneBtnBorder, 1.0f, 0);
|
||||
|
||||
HDC dc = bmp->getDC();
|
||||
if (!dc) return;
|
||||
RECT rc{b.x, b.y, b.x + b.width, b.y + b.height};
|
||||
SetBkMode(dc, TRANSPARENT);
|
||||
SetTextColor(dc, kRgbPruneBtnText);
|
||||
DrawText(dc, "Prune", -1, &rc,
|
||||
DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
|
||||
}
|
||||
|
||||
// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip.
|
||||
// Shared by the footer click (cycle mode) and the scroll-wheel (Manual fine-adjust)
|
||||
// so both agree on the hit target.
|
||||
@@ -902,6 +942,7 @@ void paintPanel(HWND hwnd, HDC hdc) {
|
||||
|
||||
drawModeSwitch(&bmp, w);
|
||||
drawTailFooter(&bmp, w, h);
|
||||
drawPruneButton(&bmp, w, h); // R3: raised over the footer strip
|
||||
|
||||
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
|
||||
}
|
||||
@@ -1596,6 +1637,20 @@ void handleClick(int x, int y) {
|
||||
}
|
||||
}
|
||||
|
||||
// Prune button (R3): checked BEFORE the footer's tail-cycle so a click on the button
|
||||
// fires prune, not a tail cycle. Fires the "Prune bank folder" action THROUGH its
|
||||
// registered command id (fork R-E: dispatch the command, do not call the session
|
||||
// directly), so the panel affordance and the bindable action share the one guarded
|
||||
// dry-run/confirm/delete path in doBankPruneFolder. A 0 id (pre-registration) no-ops.
|
||||
{
|
||||
const ButtonRect pb = pruneButtonRectFor(w, h);
|
||||
if (hitTestPruneButton(x, y, pb)) {
|
||||
const int cmd = bankPruneCommandId();
|
||||
if (cmd != 0) Main_OnCommand(cmd, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Tail footer: a click anywhere in the bottom strip cycles the tail mode
|
||||
// (None -> Auto -> Manual -> None) and repaints. It mutates the SESSION's tail
|
||||
// setting (which the capture actions read and persist saves with the project) and
|
||||
|
||||
+153
-16
@@ -81,6 +81,17 @@
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
// Move-to-trash surface (fork R-C, trash-preferred). On Windows the Recycle Bin is
|
||||
// reached via SHFileOperationW + FOF_ALLOWUNDO (verified against the Windows SDK
|
||||
// shellapi.h: SHFILEOPSTRUCTW { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... },
|
||||
// FO_DELETE=0x3, FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on the SWELL
|
||||
// (macOS/Linux) side of this codebase, so those platforms fall back to unlink behind the
|
||||
// R3 dry-run/confirm guardrail — see deleteOrphanFile below for the per-platform routing.
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#endif
|
||||
|
||||
#include "app_version.h"
|
||||
#include "capture_paths.h"
|
||||
#include "prune_reconcile.h"
|
||||
@@ -253,14 +264,32 @@ namespace {
|
||||
// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling.
|
||||
constexpr std::size_t kPruneListDisplayCap = 64;
|
||||
|
||||
} // namespace
|
||||
// A fresh enumerate + pure-core prune compute for the active project. Shared by the
|
||||
// dry-run report (pruneDryRun), the full-set query (pruneOrphanSet), and the deletion
|
||||
// (pruneReclaim) so all three agree on ONE resolution + enumeration + set-algebra path
|
||||
// (no divergence between what is shown and what is deleted). REAPER-facing (resolves the
|
||||
// active project, enumerates the folder) but writes nothing.
|
||||
//
|
||||
// * bankDirAbs — the resolved CURRENT bank folder (absolute, forward-slashed). Empty
|
||||
// when there is no active/saved project, no project dir, or no folder on
|
||||
// disk yet -> the caller treats an empty dir as "nothing to reclaim".
|
||||
// * orphans — the FULL orphan set (owned ∩ present) − referenced, in enumeration
|
||||
// order, untruncated. The pure core decides; this only supplies inputs.
|
||||
// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd).
|
||||
struct PruneScan {
|
||||
std::string bankDirAbs;
|
||||
std::vector<std::string> orphans;
|
||||
std::unordered_map<std::string, std::uint64_t> sizeByRel;
|
||||
};
|
||||
|
||||
PruneReport ReaSamplerSession::pruneDryRun() const {
|
||||
PruneReport report;
|
||||
// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem
|
||||
// call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI.
|
||||
PruneScan scanPruneOrphans(const BankBook& book, const OwnedFileManifest& owned) {
|
||||
PruneScan scan;
|
||||
|
||||
std::string rppPath;
|
||||
void* proj = readActiveProject(rppPath);
|
||||
if (!proj || rppPath.empty()) return report; // no active/saved project -> nothing
|
||||
if (!proj || rppPath.empty()) return scan; // no active/saved project -> empty scan
|
||||
|
||||
// Resolve the CURRENT bank folder the same way the index does (M4): project dir of
|
||||
// the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a
|
||||
@@ -268,11 +297,11 @@ PruneReport ReaSamplerSession::pruneDryRun() const {
|
||||
// arithmetic; feeding it the bank subfolder as the "relative path" yields the folder.
|
||||
const std::string projectDir = projectDirOf(rppPath);
|
||||
const std::string bankDir = resolveBankFile(projectDir, kBankSubfolder);
|
||||
if (bankDir.empty()) return report; // unresolvable (no project dir) -> nothing
|
||||
if (bankDir.empty()) return scan; // unresolvable (no project dir) -> empty scan
|
||||
|
||||
std::error_code ec;
|
||||
if (!fs::exists(bankDir, ec) || !fs::is_directory(bankDir, ec)) {
|
||||
return report; // no bank folder captured yet -> nothing to reclaim
|
||||
return scan; // no bank folder captured yet -> nothing to reclaim
|
||||
}
|
||||
|
||||
// Enumerate the folder into project-relative index-spelled paths, spelled the SAME
|
||||
@@ -285,7 +314,6 @@ PruneReport ReaSamplerSession::pruneDryRun() const {
|
||||
// failure (file removed, permission flip) breaks out with a best-effort partial list
|
||||
// rather than propagating std::filesystem_error across REAPER's C ABI.
|
||||
std::vector<std::string> present;
|
||||
std::unordered_map<std::string, std::uint64_t> sizeByRel;
|
||||
fs::directory_iterator it(bankDir, ec);
|
||||
for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) {
|
||||
const auto& entry = *it;
|
||||
@@ -297,17 +325,126 @@ PruneReport ReaSamplerSession::pruneDryRun() const {
|
||||
present.push_back(rel);
|
||||
std::error_code sz_ec;
|
||||
const std::uintmax_t sz = entry.file_size(sz_ec);
|
||||
sizeByRel[rel] = sz_ec ? 0 : static_cast<std::uint64_t>(sz);
|
||||
scan.sizeByRel[rel] = sz_ec ? 0 : static_cast<std::uint64_t>(sz);
|
||||
}
|
||||
|
||||
// The decision lives in the pure core — read-only inputs from the session's book and
|
||||
// manifest (NO save, NO MarkProjectDirty, NO mutation). referencedPaths() unions
|
||||
// across the whole book (pool included); owned().paths() is the manifest set. The
|
||||
// count / byte-sum / display-truncation tally is the pure buildPruneReport, so this
|
||||
// shell only enumerates, resolves, and stats — no report logic re-implemented here.
|
||||
const std::vector<std::string> orphans =
|
||||
pruneOrphans(present, book_.referencedPaths(), owned_.paths());
|
||||
return buildPruneReport(orphans, sizeByRel, kPruneListDisplayCap);
|
||||
// The decision lives in the pure core — read-only inputs from the book and manifest.
|
||||
// referencedPaths() unions across the whole book (pool included); owned().paths() is
|
||||
// the manifest set. This shell only enumerates, resolves, and stats.
|
||||
scan.bankDirAbs = bankDir;
|
||||
scan.orphans = pruneOrphans(present, book.referencedPaths(), owned.paths());
|
||||
return scan;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PruneReport ReaSamplerSession::pruneDryRun() const {
|
||||
const PruneScan scan = scanPruneOrphans(book_, owned_);
|
||||
// buildPruneReport tallies count / byte-sum / display-truncation — no report logic
|
||||
// re-implemented here. An empty scan (no project / no folder) yields a zero report.
|
||||
return buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
|
||||
}
|
||||
|
||||
std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
|
||||
return scanPruneOrphans(book_, owned_).orphans; // FULL set, untruncated
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Deletes ONE orphan file, trash-preferred (fork R-C, settled). Returns true iff the
|
||||
// file is gone from disk after the call (deleted here, OR already absent — an already-
|
||||
// vanished file is a success for the reclaim's purpose, not a failure). `absPath` is the
|
||||
// resolved absolute path (forward-slashed). NON-THROWING: no exception may cross the C ABI.
|
||||
//
|
||||
// Per-platform routing:
|
||||
// * Windows — SHFileOperationW(FO_DELETE, pFrom=<double-NUL path>, FOF_ALLOWUNDO |
|
||||
// FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI). FOF_ALLOWUNDO routes to the
|
||||
// Recycle Bin (recoverable); the no-UI flags suppress REAPER-blocking dialogs (our
|
||||
// own confirm already happened). Verified against shellapi.h. `outUsedTrash` set true.
|
||||
// * Other (SWELL: macOS/Linux) — no portable move-to-trash surface is available in this
|
||||
// codebase, so fall back to std::filesystem::remove (hard unlink) behind the R3
|
||||
// confirm guardrail. `outUsedTrash` left as-is (false).
|
||||
bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash) {
|
||||
#ifdef _WIN32
|
||||
// Convert forward-slashed UTF-8 to a back-slashed, double-NUL-terminated wide string.
|
||||
// SHFileOperation's pFrom is a list; a single path still needs the extra terminating
|
||||
// NUL. Backslashes are required (shell APIs reject forward slashes in some cases).
|
||||
std::string win = absPath;
|
||||
for (char& c : win) if (c == '/') c = '\\';
|
||||
|
||||
const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0);
|
||||
if (wlen <= 0) return false; // conversion failed -> report as skip
|
||||
std::vector<wchar_t> wbuf(static_cast<std::size_t>(wlen) + 1, L'\0'); // +1 for list NUL
|
||||
MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen);
|
||||
// wbuf now holds the path + its NUL at [wlen-1]; the extra trailing L'\0' at [wlen]
|
||||
// makes it the double-NUL-terminated single-element list SHFileOperation wants.
|
||||
|
||||
SHFILEOPSTRUCTW op{};
|
||||
op.hwnd = nullptr;
|
||||
op.wFunc = FO_DELETE;
|
||||
op.pFrom = wbuf.data();
|
||||
op.pTo = nullptr;
|
||||
op.fFlags = static_cast<FILEOP_FLAGS>(FOF_ALLOWUNDO | FOF_NOCONFIRMATION |
|
||||
FOF_SILENT | FOF_NOERRORUI);
|
||||
const int rv = SHFileOperationW(&op);
|
||||
if (rv == 0 && !op.fAnyOperationsAborted) {
|
||||
outUsedTrash = true;
|
||||
return true;
|
||||
}
|
||||
// SHFileOperation failed (e.g. file already gone yields a nonzero code on some
|
||||
// versions, or a lock). Treat "already absent" as success; otherwise a real skip.
|
||||
std::error_code ec;
|
||||
return !fs::exists(absPath, ec);
|
||||
#else
|
||||
// No portable trash surface on SWELL platforms -> hard unlink behind the confirm.
|
||||
std::error_code ec;
|
||||
const bool removed = fs::remove(absPath, ec);
|
||||
if (removed) return true; // deleted this call
|
||||
if (ec) return false; // a real failure (locked / permission) -> skip
|
||||
// remove returned false with no error == the file did not exist -> already gone.
|
||||
return !fs::exists(absPath, ec);
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PruneDeletionResult ReaSamplerSession::pruneReclaim(
|
||||
const std::vector<std::string>& confirmed) const {
|
||||
PruneDeletionResult result;
|
||||
|
||||
// Re-enumerate + run the pure core FRESH (never a stale set): the deletion targets
|
||||
// exactly `confirmed ∩ freshOrphans` (pruneDeletePlan). A file that vanished or became
|
||||
// referenced between confirm and delete drops out of freshOrphans and is skipped; a
|
||||
// newly-appeared orphan not in `confirmed` is never swept without its own confirm.
|
||||
// Because freshOrphans is itself a pure-core output, the plan can contain NO referenced
|
||||
// and NO hand-dropped file — the R-C/R-D safety survives the recompute.
|
||||
const PruneScan scan = scanPruneOrphans(book_, owned_);
|
||||
if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing
|
||||
|
||||
const std::vector<std::string> plan = pruneDeletePlan(confirmed, scan.orphans);
|
||||
|
||||
// Anything the user confirmed but that is no longer a fresh orphan is a staleness skip.
|
||||
result.skippedCount += confirmed.size() - plan.size();
|
||||
|
||||
for (const std::string& rel : plan) {
|
||||
// Reconstruct the absolute path from the resolved bank dir + the entry's file name.
|
||||
// rel is index-spelled "<kBankSubfolder>/<name>"; the name is the tail after '/'.
|
||||
const std::string::size_type slash = rel.find_last_of('/');
|
||||
const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1);
|
||||
if (name.empty()) { ++result.skippedCount; continue; }
|
||||
const std::string absPath = scan.bankDirAbs + "/" + name;
|
||||
|
||||
const auto szIt = scan.sizeByRel.find(rel);
|
||||
const std::uint64_t bytes = (szIt != scan.sizeByRel.end()) ? szIt->second : 0;
|
||||
|
||||
if (deleteOrphanFile(absPath, result.usedTrash)) {
|
||||
++result.reclaimedCount;
|
||||
result.reclaimedBytes += bytes;
|
||||
} else {
|
||||
++result.skippedCount; // locked / conversion failure -> recorded, not thrown
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -195,6 +195,38 @@ public:
|
||||
// folder on disk yet — an unsaved or never-captured project has nothing to reclaim.
|
||||
PruneReport pruneDryRun() const;
|
||||
|
||||
// The FULL (untruncated) prune orphan set for the ACTIVE project — the same fresh
|
||||
// enumerate + pure-core compute pruneDryRun() runs, but returning EVERY orphan (no
|
||||
// 64-cap display clip) as project-relative index-spelled paths, in enumeration order.
|
||||
// The R3 action calls this to obtain the exact set it will CONFIRM and then delete
|
||||
// (pruneDryRun's truncated list is for the console readout; the delete set must be
|
||||
// complete). READ-ONLY — no ext-state, no save, no file mutation. Empty when there is
|
||||
// no active/saved project or no bank folder yet.
|
||||
std::vector<std::string> pruneOrphanSet() const;
|
||||
|
||||
// Phase R (Reclaim), R3: DELETE the confirmed orphan set — the SOLE file-deletion path
|
||||
// in ReaSampler, callable ONLY after an explicit user confirm of a specific manifest.
|
||||
// Given the orphan set the user was shown and confirmed (`confirmed`, typically the
|
||||
// full pruneOrphanSet() captured moments earlier), this re-enumerates the folder, runs
|
||||
// the pure core FRESH, and deletes exactly `confirmed ∩ freshOrphans` (pruneDeletePlan)
|
||||
// so a file that vanished or became referenced between confirm and delete is skipped,
|
||||
// never wrongly deleted — and a newly-appeared orphan the user did NOT see is never
|
||||
// swept. Deletion routes to the OS trash where a portable move-to-trash is verified
|
||||
// (Windows Recycle Bin via SHFileOperation + FOF_ALLOWUNDO); elsewhere it falls back to
|
||||
// std::filesystem unlink behind this confirm guardrail (see persist.cpp for per-platform
|
||||
// routing). Non-throwing: every filesystem call uses error_code forms; a per-file
|
||||
// failure (locked, already gone) is recorded and skipped, never thrown across the C ABI.
|
||||
//
|
||||
// Does NOT modify the BankIndex/book (orphans are unreferenced by definition) and does
|
||||
// NOT modify the OwnedFileManifest (a reclaimed file drops out of the (owned ∩ present)
|
||||
// algebra naturally once it is off disk — no persist write, so no undo-point question
|
||||
// and no risk to the referenced/owned safety). Writes NO ext-state at all.
|
||||
//
|
||||
// No-ops (empty result) when there is no active/saved project, no bank folder, or the
|
||||
// delete plan is empty (everything went stale). The caller is responsible for having
|
||||
// shown the confirm; this method does NOT prompt.
|
||||
PruneDeletionResult pruneReclaim(const std::vector<std::string>& confirmed) const;
|
||||
|
||||
// Poll the active project. Detects a project load (active project changed)
|
||||
// and a Save-As (active project's .rpp path changed) and reacts accordingly.
|
||||
// Intended to be driven by REAPER's "timer" register. Idempotent per tick.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "prune_button.h"
|
||||
|
||||
// prune_button implementation — right-anchored button placement in the footer strip,
|
||||
// with a left-collision suppression rule. Trivially auditable arithmetic; the safety
|
||||
// property (a suppressed/empty button never claims a click) is a pure predicate tested
|
||||
// outside the DAW.
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& spec) {
|
||||
if (footer.width <= 0 || footer.height <= 0) return ButtonRect{}; // degenerate footer
|
||||
if (spec.buttonWidth <= 0) return ButtonRect{}; // nothing to place
|
||||
|
||||
// Right-anchored: right edge inset from the footer's right; width fixed.
|
||||
const int right = footer.x + footer.width - spec.rightInset;
|
||||
const int left = right - spec.buttonWidth;
|
||||
|
||||
// Suppress if the button would encroach past the reserved left inset (tail label room)
|
||||
// or spill off the left of the footer entirely.
|
||||
if (left < footer.x + spec.minLeftInset) return ButtonRect{};
|
||||
|
||||
// Vertically centred by the inset; clamp so a thin footer never yields a negative height.
|
||||
int top = footer.y + spec.verticalInset;
|
||||
int height = footer.height - 2 * spec.verticalInset;
|
||||
if (height <= 0) {
|
||||
top = footer.y;
|
||||
height = footer.height;
|
||||
}
|
||||
|
||||
return ButtonRect{left, top, spec.buttonWidth, height};
|
||||
}
|
||||
|
||||
bool hitTestPruneButton(int px, int py, const ButtonRect& button) {
|
||||
if (button.empty()) return false; // suppressed button claims nothing
|
||||
return px >= button.x && px < button.x + button.width &&
|
||||
py >= button.y && py < button.y + button.height;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
// prune_button — the REAPER-free layout math behind the bank_panel's Prune button
|
||||
// (Phase R, Wave 3 — R3, fork R-E). A single labelled button drawn in the panel's
|
||||
// tail-footer strip that fires the "Prune bank folder" command. The panel shell
|
||||
// (bank_panel.cpp) owns the SWELL window, LICE drawing, and the Main_OnCommand
|
||||
// dispatch of the registered command id — all REAPER-bound, DAW-verified. What is
|
||||
// NOT DAW-bound — WHERE the button sits in the footer and whether a click lands on
|
||||
// it — lives here so it is unit-tested outside the DAW (CLAUDE.md §load-bearing
|
||||
// split). Mirror of mode_switch / tab_strip.
|
||||
//
|
||||
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
||||
// only. Builds and unit-tests without REAPER.
|
||||
//
|
||||
// -- Placement contract --------------------------------------------------------
|
||||
//
|
||||
// The footer already hosts a LEFT-aligned tail-mode label and a RIGHT-aligned
|
||||
// version readout (bank_panel drawTailFooter). The prune button is a fixed-width
|
||||
// button anchored to the RIGHT of the footer, inset from the right edge, sitting
|
||||
// just LEFT of the version readout's inset region. It never overlaps the tail label
|
||||
// at the left. When the footer is too narrow to fit the button without colliding
|
||||
// with the left inset, the button is suppressed (empty rect) rather than drawn on
|
||||
// top of the label — the action is always reachable via its bindable command, so a
|
||||
// hidden button is a graceful degradation, not a lost affordance.
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The footer strip the button is drawn into, top-left origin (SWELL/LICE
|
||||
// convention). (x, y) is the top-left corner; width/height are the strip extents.
|
||||
// bank_panel derives this from panelFooter() and passes it here.
|
||||
struct FooterRect {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
bool operator==(const FooterRect& o) const {
|
||||
return x == o.x && y == o.y && width == o.width && height == o.height;
|
||||
}
|
||||
};
|
||||
|
||||
// A button's pixel rectangle within the footer, top-left origin. A zero-area rect
|
||||
// (width <= 0 or height <= 0) means "no button" — the footer is too narrow to place
|
||||
// it, or the footer itself is degenerate; the caller must not draw or hit-test it.
|
||||
struct ButtonRect {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
bool empty() const { return width <= 0 || height <= 0; }
|
||||
|
||||
bool operator==(const ButtonRect& o) const {
|
||||
return x == o.x && y == o.y && width == o.width && height == o.height;
|
||||
}
|
||||
};
|
||||
|
||||
// Layout inputs for the prune button, in pixels. Defaults match the bank_panel footer
|
||||
// metrics; the shell passes its own so draw and hit-test share one source of truth.
|
||||
// * buttonWidth — the button's fixed width.
|
||||
// * rightInset — gap from the footer's right edge to the button's right edge (the
|
||||
// button sits left of this inset, clearing the right-aligned version
|
||||
// readout which uses its own inset).
|
||||
// * verticalInset — top/bottom gap inside the footer (the button is shorter than the
|
||||
// strip so it reads as a raised control, not a full-height fill).
|
||||
// * minLeftInset — the button's left edge must stay at least this far from the footer
|
||||
// left edge (reserving room for the left-aligned tail label). If the
|
||||
// button would encroach past this, computePruneButton yields an empty
|
||||
// rect (button suppressed — see header placement contract).
|
||||
struct PruneButtonSpec {
|
||||
int buttonWidth = 72;
|
||||
int rightInset = 84;
|
||||
int verticalInset = 4;
|
||||
int minLeftInset = 120;
|
||||
};
|
||||
|
||||
// Computes the prune button's rect within `footer` per `spec`. Right-anchored: the
|
||||
// button's right edge is footer.x + footer.width - rightInset, its width is buttonWidth,
|
||||
// and it is vertically centred by verticalInset. Returns an EMPTY rect (button
|
||||
// suppressed) when: the footer is degenerate (width/height <= 0), OR the resulting left
|
||||
// edge would fall closer to the footer left than minLeftInset (too narrow to place
|
||||
// without colliding with the tail label). The action stays reachable via its command in
|
||||
// that case — a suppressed button is graceful, not a lost feature.
|
||||
ButtonRect computePruneButton(const FooterRect& footer, const PruneButtonSpec& spec);
|
||||
|
||||
// True iff the point (px, py) (SWELL/LICE top-left client coords) falls inside `button`.
|
||||
// Half-open bounds [x, x+width) x [y, y+height) — matches computePruneButton so draw and
|
||||
// hit-test agree on the same pixels. An empty button never claims a point (always false),
|
||||
// so a suppressed button cannot be accidentally clicked.
|
||||
bool hitTestPruneButton(int px, int py, const ButtonRect& button);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -52,4 +52,21 @@ PruneReport buildPruneReport(
|
||||
return report;
|
||||
}
|
||||
|
||||
std::vector<std::string> pruneDeletePlan(const std::vector<std::string>& confirmed,
|
||||
const std::vector<std::string>& freshOrphans) {
|
||||
// fresh is a pure-core output (referenced/hand-dropped already excluded), so keeping a
|
||||
// confirmed path iff it is still a fresh orphan can never re-admit an unsafe file. Walk
|
||||
// `confirmed` to preserve the confirm's listing order; emitted de-dups repeats.
|
||||
const std::unordered_set<std::string> freshSet(freshOrphans.begin(), freshOrphans.end());
|
||||
|
||||
std::vector<std::string> plan;
|
||||
std::unordered_set<std::string> emitted;
|
||||
for (const std::string& path : confirmed) {
|
||||
if (freshSet.count(path) == 0) continue; // stale: vanished / now-referenced -> skip
|
||||
if (!emitted.insert(path).second) continue; // already emitted
|
||||
plan.push_back(path);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -71,6 +71,26 @@ struct PruneReport {
|
||||
bool truncated = false;
|
||||
};
|
||||
|
||||
// The outcome of an actual prune DELETION (Phase R, Wave 3 — R3). The prune shell fills
|
||||
// this as it deletes the confirmed orphan set, reporting what it ACTUALLY reclaimed (not
|
||||
// what it intended to) so a locked/vanished file shows up as a skip, never a false claim.
|
||||
// REAPER-free / filesystem-free by design (the shell does the deletion; this is the
|
||||
// tallied outcome), so the count/byte aggregation is unit-testable outside the DAW.
|
||||
//
|
||||
// * reclaimedCount — number of files actually removed from disk (trash or unlink).
|
||||
// * reclaimedBytes — sum of the on-disk sizes of the files actually removed, in bytes.
|
||||
// * skippedCount — planned files that could NOT be removed (locked, disappeared, a
|
||||
// trash/unlink failure) OR that went stale between confirm and delete
|
||||
// (dropped by the delete-plan intersection). Never an error/crash.
|
||||
// * usedTrash — true iff the deletions were routed to the OS trash/recycle bin
|
||||
// (recoverable); false iff the platform fell back to hard unlink.
|
||||
struct PruneDeletionResult {
|
||||
std::size_t reclaimedCount = 0;
|
||||
std::uint64_t reclaimedBytes = 0;
|
||||
std::size_t skippedCount = 0;
|
||||
bool usedTrash = false;
|
||||
};
|
||||
|
||||
// Computes the prune orphan set: (owned ∩ present) − referenced.
|
||||
//
|
||||
// Returns the subset of `present` that is BOTH owned AND unreferenced, in the ORDER
|
||||
@@ -105,4 +125,24 @@ PruneReport buildPruneReport(const std::vector<std::string>& orphans,
|
||||
const std::unordered_map<std::string, std::uint64_t>& sizeByPath,
|
||||
std::size_t displayCap);
|
||||
|
||||
// Computes the confirm-time delete plan: the intersection of the set the user was SHOWN
|
||||
// and confirmed (`confirmed`) with a FRESH pure-core orphan output (`freshOrphans`) taken
|
||||
// at delete time. Returns exactly `confirmed ∩ freshOrphans`, in the order of `confirmed`
|
||||
// (deterministic — the same order the confirm listed).
|
||||
//
|
||||
// This is the R3 staleness guard, and it protects in BOTH directions so that "what was
|
||||
// shown is what is deleted" holds no matter what changed between confirm and delete:
|
||||
// * A confirmed path that is NO LONGER a fresh orphan — a file that vanished (gone from
|
||||
// `present`), or that some bank now references (gone from `− referenced`), or whose
|
||||
// ownership changed — is DROPPED (a skip, never an error, never a wrongful delete of a
|
||||
// now-referenced file). Because `freshOrphans` is itself a pure-core output, the plan
|
||||
// can never contain a referenced or hand-dropped file: the guard survives recompute.
|
||||
// * A path that became an orphan AFTER the confirm (in `freshOrphans` but not `confirmed`)
|
||||
// is NOT deleted — it was never shown, so it is never swept without its own confirm.
|
||||
//
|
||||
// PURE: no I/O, no REAPER. Duplicate spellings in `confirmed` are de-duplicated in the
|
||||
// result (mirrors pruneOrphans; a confirmed set from a real scan holds distinct names).
|
||||
std::vector<std::string> pruneDeletePlan(const std::vector<std::string>& confirmed,
|
||||
const std::vector<std::string>& freshOrphans);
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
Reference in New Issue
Block a user