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:
+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 {
|
||||
|
||||
Reference in New Issue
Block a user