feat: asset deletion scan and delete dialog (WIP)

Adds AssetDeletionScan to Core (no UI/editor-pipeline deps, invariant holds)
and SAssetDeleteDialog to the Editor module, plus 10 spec cases.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-07 17:14:39 +07:00
parent 243bb1fc55
commit ee1c8bad3e
8 changed files with 1734 additions and 0 deletions
@@ -0,0 +1,669 @@
// NextGenium 2026. Asset Usage Audit.
#include "SAssetDeleteDialog.h"
#include "AssetDeletionScan.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "AssetUsageAuditEditorModule.h"
#include "Framework/Application/SlateApplication.h"
#include "Framework/Docking/TabManager.h"
#include "ObjectTools.h"
#include "Styling/AppStyle.h"
#include "Styling/CoreStyle.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Input/SCheckBox.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SBox.h"
#include "Widgets/Layout/SSeparator.h"
#include "Widgets/SBoxPanel.h"
#include "Widgets/SWindow.h"
#include "Widgets/Text/STextBlock.h"
#include "Widgets/Views/SHeaderRow.h"
#include "Widgets/Views/SListView.h"
#define LOCTEXT_NAMESPACE "AssetDeleteDialog"
namespace DeleteColumns
{
static const FName Check(TEXT("Check"));
static const FName Name(TEXT("Name"));
static const FName Type(TEXT("Type"));
static const FName Path(TEXT("Path"));
static const FName Referencers(TEXT("Referencers"));
static const FName Status(TEXT("Status"));
}
namespace
{
/** Red enough to read as a warning, light enough to stay legible on the dark editor theme. */
const FLinearColor DangerColor(0.95f, 0.35f, 0.30f);
const FLinearColor CautionColor(0.95f, 0.72f, 0.25f);
const FLinearColor MutedColor(0.6f, 0.6f, 0.6f);
}
/** One candidate plus its tick state. */
struct FDeleteRow
{
AssetDeletionScan::FCandidate Candidate;
/**
* Ticked by default when nothing objects.
*
* The user already ticked these rows in the panel; re-ticking them here would be busywork.
* What the window adds is the chance to *un*tick, having now been shown the referencers.
*/
bool bChecked = false;
};
using FDeleteRowPtr = TSharedPtr<FDeleteRow>;
class SDeleteRowWidget : public SMultiColumnTableRow<FDeleteRowPtr>
{
public:
SLATE_BEGIN_ARGS(SDeleteRowWidget) {}
SLATE_ARGUMENT(FDeleteRowPtr, Row)
SLATE_EVENT(FSimpleDelegate, OnCheckChanged)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs, const TSharedRef<STableViewBase>& InOwnerTable)
{
Row = InArgs._Row;
OnCheckChanged = InArgs._OnCheckChanged;
SMultiColumnTableRow<FDeleteRowPtr>::Construct(FSuperRowType::FArguments(), InOwnerTable);
}
virtual TSharedRef<SWidget> GenerateWidgetForColumn(const FName& ColumnName) override
{
if (!Row.IsValid())
{
return SNullWidget::NullWidget;
}
const AssetDeletionScan::FCandidate& Candidate = Row->Candidate;
const bool bCanDelete = Candidate.CanDelete();
if (ColumnName == DeleteColumns::Check)
{
return SNew(SBox)
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SNew(SCheckBox)
.IsEnabled(bCanDelete)
.IsChecked_Lambda([this]()
{
return Row->bChecked ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
})
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState)
{
Row->bChecked = (NewState == ECheckBoxState::Checked);
OnCheckChanged.ExecuteIfBound();
})
];
}
if (ColumnName == DeleteColumns::Name)
{
return SNew(STextBlock)
.Text(FText::FromName(Candidate.AssetName))
.ColorAndOpacity(bCanDelete ? FSlateColor::UseForeground() : FSlateColor(MutedColor))
.ToolTipText(FText::FromName(Candidate.PackageName));
}
if (ColumnName == DeleteColumns::Type)
{
return SNew(STextBlock)
.Text(FText::FromString(Candidate.ClassPath.GetAssetName().ToString()))
.ColorAndOpacity(FSlateColor(MutedColor));
}
if (ColumnName == DeleteColumns::Path)
{
return SNew(STextBlock)
.Text(FText::FromString(Candidate.PathFromProjectRoot))
.ColorAndOpacity(FSlateColor(MutedColor))
.ToolTipText(FText::FromString(Candidate.PathFromProjectRoot));
}
if (ColumnName == DeleteColumns::Referencers)
{
if (Candidate.OutsideReferencerCount == 0)
{
return SNew(STextBlock)
.Text(LOCTEXT("NoReferencers", "none"))
.ColorAndOpacity(FSlateColor(MutedColor))
.ToolTipText(LOCTEXT("NoReferencersTip", "Nothing outside this delete set points at it, as far as the Asset Registry can see. Config files, C++ string paths and FMOD events are outside what it can see."));
}
// The names matter more than the number: "3" tells you to stop, the names tell you
// whether stopping was right.
FString Tooltip;
for (FName Referencer : Candidate.OutsideReferencers)
{
Tooltip += Referencer.ToString() + TEXT("\n");
}
if (Candidate.OutsideReferencerCount > Candidate.OutsideReferencers.Num())
{
Tooltip += FString::Printf(TEXT("... and %d more"),
Candidate.OutsideReferencerCount - Candidate.OutsideReferencers.Num());
}
const FText Label = Candidate.LevelReferencerCount > 0
? FText::Format(
LOCTEXT("ReferencersWithLevels", "{0} ({1} on levels)"),
FText::AsNumber(Candidate.OutsideReferencerCount),
FText::AsNumber(Candidate.LevelReferencerCount))
: FText::AsNumber(Candidate.OutsideReferencerCount);
return SNew(STextBlock)
.Text(Label)
.ColorAndOpacity(FSlateColor(CautionColor))
.ToolTipText(FText::FromString(Tooltip));
}
if (ColumnName == DeleteColumns::Status)
{
if (!bCanDelete)
{
return SNew(STextBlock)
.Text(AssetDeletionScan::DescribeRefusal(Candidate.Refusal))
.ColorAndOpacity(FSlateColor(MutedColor));
}
if (Candidate.bReadOnlyOnDisk)
{
return SNew(STextBlock)
.Text(LOCTEXT("ReadOnly", "read-only on disk"))
.ColorAndOpacity(FSlateColor(CautionColor))
.ToolTipText(LOCTEXT("ReadOnlyTip", "The file is not writable. This project is Perforce-primary, so an unopened file is read-only and the delete will fail on it. Check it out first."));
}
return SNew(STextBlock)
.Text(LOCTEXT("WillDelete", "will be deleted"))
.ColorAndOpacity(FSlateColor(DangerColor));
}
return SNullWidget::NullWidget;
}
private:
FDeleteRowPtr Row;
FSimpleDelegate OnCheckChanged;
};
/**
* The window body.
*
* Owns the scan result and the tick state; deletes nothing until Delete is pressed, and reports
* back through the outcome struct rather than mutating anything the panel owns.
*/
class SAssetDeleteDialog : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SAssetDeleteDialog) {}
SLATE_ARGUMENT(TArray<FName>, Packages)
SLATE_ARGUMENT(TSharedPtr<SWindow>, ParentWindow)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs)
{
ParentWindow = InArgs._ParentWindow;
FAssetRegistryModule& RegistryModule =
FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry"));
IAssetRegistry& Registry = RegistryModule.Get();
AssetDeletionScan::FOptions ScanOptions;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(Registry, InArgs._Packages, ScanOptions, Stats);
for (const AssetDeletionScan::FCandidate& Candidate : Candidates)
{
FDeleteRowPtr NewRow = MakeShared<FDeleteRow>();
NewRow->Candidate = Candidate;
NewRow->bChecked = Candidate.CanDelete();
Rows.Add(NewRow);
}
UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Delete scan: %s"), *Stats.Summarise());
BuildHeaderRow();
ChildSlot
[
SNew(SBorder)
.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
.Padding(12.0f)
[
SNew(SVerticalBox)
// --- The headline ------------------------------------------------------------
//
// Stated as a consequence rather than as a question. "Delete 12 assets?" invites a
// reflex click; naming what disappears does not.
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 6.0f)
[
SNew(STextBlock)
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 13))
.ColorAndOpacity(FSlateColor(DangerColor))
.Text(FText::Format(
LOCTEXT("Headline", "{0} asset file(s) will be deleted from this project."),
FText::AsNumber(Stats.Deletable)))
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(STextBlock)
.AutoWrapText(true)
.Text(LOCTEXT("Explanation",
"This removes the files from disk. It is not an export and there is no undo - what protects you is source control, so make sure this content is submitted or checked out before continuing.\n\n"
"Bear in mind what the audit cannot see: assets named from C++ or Blueprint strings, rows inside DataTables, and FMOD events are invisible to the Asset Registry. An asset with no referencers here is not proof that nothing uses it."))
]
// --- Reference warning -------------------------------------------------------
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(SBorder)
.BorderImage(FAppStyle::GetBrush("ToolPanel.DarkGroupBorder"))
.Padding(8.0f)
.Visibility_Lambda([this]()
{
return Stats.WithOutsideReferencers > 0 ? EVisibility::Visible : EVisibility::Collapsed;
})
[
SNew(STextBlock)
.AutoWrapText(true)
.ColorAndOpacity(FSlateColor(CautionColor))
.Text(FText::Format(
LOCTEXT("ReferencerWarning",
"{0} of them are still referenced by something outside this selection. Deleting those leaves the referencing assets pointing at nothing.\n"
"Unreal's own Delete Assets window opens next and lists every reference, where you can replace them with another asset or force the delete."),
FText::AsNumber(Stats.WithOutsideReferencers)))
]
]
// --- Refusals ----------------------------------------------------------------
//
// Shown, not hidden. The user ticked those rows; a delete that quietly acts on
// fewer assets than were ticked is how trust in a tool is lost.
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(STextBlock)
.AutoWrapText(true)
.ColorAndOpacity(FSlateColor(MutedColor))
.Visibility_Lambda([this]()
{
return (Stats.Requested - Stats.Deletable) > 0 ? EVisibility::Visible : EVisibility::Collapsed;
})
.Text_Lambda([this]() { return GetRefusalText(); })
]
+ SVerticalBox::Slot()
.FillHeight(1.0f)
[
SNew(SBox)
.MinDesiredHeight(240.0f)
[
SAssignNew(ListView, SListView<FDeleteRowPtr>)
.ListItemsSource(&Rows)
.SelectionMode(ESelectionMode::None)
.HeaderRow(HeaderRow)
.OnGenerateRow_Lambda([this](FDeleteRowPtr Item, const TSharedRef<STableViewBase>& OwnerTable)
{
return SNew(SDeleteRowWidget, OwnerTable)
.Row(Item)
.OnCheckChanged(FSimpleDelegate::CreateLambda([this]() { Invalidate(EInvalidateWidgetReason::Paint); }));
})
]
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 8.0f)
[
SNew(SSeparator)
]
// --- Acknowledgement ---------------------------------------------------------
//
// A deliberate second action. The Delete button stays disabled until this is
// ticked, which costs one click and removes the "I meant to press Cancel" class
// of accident entirely.
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(SCheckBox)
.IsChecked_Lambda([this]() { return bAcknowledged ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; })
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState) { bAcknowledged = (NewState == ECheckBoxState::Checked); })
[
SNew(STextBlock)
.Margin(FMargin(4.0f, 0.0f, 0.0f, 0.0f))
.Text(LOCTEXT("Acknowledge", "I understand these files will be removed from the project."))
]
]
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.ColorAndOpacity(FSlateColor(MutedColor))
.Text_Lambda([this]()
{
return FText::Format(
LOCTEXT("Footer", "{0} ticked of {1} that can be deleted."),
FText::AsNumber(CountChecked()),
FText::AsNumber(Stats.Deletable));
})
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f)
[
SNew(SButton)
.Text(LOCTEXT("Cancel", "Cancel"))
.OnClicked(this, &SAssetDeleteDialog::OnCancel)
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f)
[
SNew(SButton)
.Text_Lambda([this]()
{
return FText::Format(
LOCTEXT("DeleteButton", "Delete {0} asset(s)"),
FText::AsNumber(CountChecked()));
})
.ToolTipText(LOCTEXT("DeleteButtonTip", "Hand the ticked assets to Unreal's delete, which will show its own reference window before anything is removed."))
.IsEnabled_Lambda([this]() { return bAcknowledged && CountChecked() > 0; })
.OnClicked(this, &SAssetDeleteDialog::OnDelete)
]
]
]
];
}
bool WasAccepted() const
{
return bAccepted;
}
/** Packages the user confirmed, in the order they were listed. */
TArray<FName> GetConfirmedPackages() const
{
TArray<FName> Result;
for (const FDeleteRowPtr& Row : Rows)
{
if (Row->bChecked && Row->Candidate.CanDelete())
{
Result.Add(Row->Candidate.PackageName);
}
}
return Result;
}
const AssetDeletionScan::FStats& GetStats() const
{
return Stats;
}
private:
void BuildHeaderRow()
{
HeaderRow = SNew(SHeaderRow)
+ SHeaderRow::Column(DeleteColumns::Check)
.DefaultLabel(FText::GetEmpty())
.FixedWidth(28.0f)
[
SNew(SBox)
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SNew(SCheckBox)
.IsChecked_Lambda([this]() { return GetHeaderCheckState(); })
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState) { SetAllChecked(NewState == ECheckBoxState::Checked); })
]
]
+ SHeaderRow::Column(DeleteColumns::Name)
.DefaultLabel(LOCTEXT("ColName", "Asset"))
.FillWidth(0.24f)
+ SHeaderRow::Column(DeleteColumns::Type)
.DefaultLabel(LOCTEXT("ColType", "Type"))
.FillWidth(0.13f)
+ SHeaderRow::Column(DeleteColumns::Path)
.DefaultLabel(LOCTEXT("ColPath", "Path"))
.FillWidth(0.33f)
+ SHeaderRow::Column(DeleteColumns::Referencers)
.DefaultLabel(LOCTEXT("ColReferencers", "Referenced by"))
.FillWidth(0.15f)
+ SHeaderRow::Column(DeleteColumns::Status)
.DefaultLabel(LOCTEXT("ColStatus", "Status"))
.FillWidth(0.15f);
}
ECheckBoxState GetHeaderCheckState() const
{
const int32 Checked = CountChecked();
if (Checked == 0)
{
return ECheckBoxState::Unchecked;
}
return Checked == Stats.Deletable ? ECheckBoxState::Checked : ECheckBoxState::Undetermined;
}
void SetAllChecked(bool bNewChecked)
{
for (const FDeleteRowPtr& Row : Rows)
{
if (Row->Candidate.CanDelete())
{
Row->bChecked = bNewChecked;
}
}
if (ListView.IsValid())
{
ListView->RequestListRefresh();
}
}
int32 CountChecked() const
{
int32 Count = 0;
for (const FDeleteRowPtr& Row : Rows)
{
if (Row->bChecked && Row->Candidate.CanDelete())
{
++Count;
}
}
return Count;
}
FText GetRefusalText() const
{
TArray<FString> Parts;
if (Stats.RefusedLevels > 0)
{
Parts.Add(FString::Printf(TEXT("%d level(s)"), Stats.RefusedLevels));
}
if (Stats.RefusedExternal > 0)
{
Parts.Add(FString::Printf(TEXT("%d actor package(s)"), Stats.RefusedExternal));
}
if (Stats.RefusedNotProjectContent > 0)
{
Parts.Add(FString::Printf(TEXT("%d outside project content"), Stats.RefusedNotProjectContent));
}
if (Stats.RefusedMissing > 0)
{
Parts.Add(FString::Printf(TEXT("%d already gone"), Stats.RefusedMissing));
}
return FText::Format(
LOCTEXT("RefusalSummary", "Kept out of the delete: {0}. They are listed below with a reason, and cannot be ticked."),
FText::FromString(FString::Join(Parts, TEXT(", "))));
}
FReply OnDelete()
{
bAccepted = true;
CloseWindow();
return FReply::Handled();
}
FReply OnCancel()
{
bAccepted = false;
CloseWindow();
return FReply::Handled();
}
void CloseWindow()
{
if (ParentWindow.IsValid())
{
ParentWindow->RequestDestroyWindow();
}
}
TArray<FDeleteRowPtr> Rows;
AssetDeletionScan::FStats Stats;
bool bAcknowledged = false;
bool bAccepted = false;
TSharedPtr<SWindow> ParentWindow;
TSharedPtr<SListView<FDeleteRowPtr>> ListView;
TSharedPtr<SHeaderRow> HeaderRow;
};
FAssetDeleteOutcome ShowAssetDeleteDialog(const TArray<FName>& Packages)
{
FAssetDeleteOutcome Outcome;
Outcome.Requested = Packages.Num();
FAssetRegistryModule& RegistryModule =
FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry"));
IAssetRegistry& Registry = RegistryModule.Get();
// The engine refuses to delete while the registry is still discovering, and does so with a
// transient notification that is easy to miss. Better to wait here than to have the delete
// quietly do nothing.
if (Registry.IsLoadingAssets())
{
Registry.WaitForCompletion();
}
TSharedRef<SWindow> Window = SNew(SWindow)
.Title(LOCTEXT("WindowTitle", "Delete ticked assets"))
.ClientSize(FVector2D(940.0f, 620.0f))
.SupportsMinimize(false)
.SupportsMaximize(false);
TSharedRef<SAssetDeleteDialog> Dialog = SNew(SAssetDeleteDialog)
.Packages(Packages)
.ParentWindow(Window);
Window->SetContent(Dialog);
FSlateApplication::Get().AddModalWindow(Window, FGlobalTabmanager::Get()->GetRootWindow());
Outcome.Refused = Dialog->GetStats().Requested - Dialog->GetStats().Deletable;
if (!Dialog->WasAccepted())
{
UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Delete cancelled; nothing was touched."));
return Outcome;
}
const TArray<FName> Confirmed = Dialog->GetConfirmedPackages();
Outcome.bCancelled = false;
Outcome.Attempted = Confirmed.Num();
if (Confirmed.Num() == 0)
{
return Outcome;
}
// Resolve to FAssetData at the last moment. The scan ran before the window opened and the
// user may have spent a while reading it; a stale FAssetData would ask the engine to delete
// something that has already moved.
TArray<FAssetData> ToDelete;
for (FName Package : Confirmed)
{
TArray<FAssetData> Assets;
Registry.GetAssetsByPackageName(Package, Assets, /*bIncludeOnlyOnDiskAssets=*/true);
ToDelete.Append(Assets);
}
if (ToDelete.Num() == 0)
{
UE_LOG(LogAssetUsageAuditEditor, Warning,
TEXT("Nothing was deleted: none of the %d confirmed packages still hold an asset."), Confirmed.Num());
return Outcome;
}
// The engine's own delete, with its confirmation left on. That window is where referencers are
// listed and where Force Delete and Replace References live - the behaviour the request asked
// to match, obtained by using it rather than by imitating it.
const int32 ObjectsDeleted = ObjectTools::DeleteAssets(ToDelete, /*bShowConfirmation=*/true);
// Count packages, not objects: the engine returns deleted UObjects, and asking the registry
// what actually disappeared is the only figure that matches the Content Browser.
for (FName Package : Confirmed)
{
TArray<FAssetData> Remaining;
Registry.GetAssetsByPackageName(Package, Remaining, /*bIncludeOnlyOnDiskAssets=*/true);
if (Remaining.Num() == 0)
{
Outcome.DeletedPackages.Add(Package);
}
}
UE_LOG(LogAssetUsageAuditEditor, Display,
TEXT("Delete finished: %d package(s) gone of %d confirmed (%d object(s) reported by the engine)."),
Outcome.DeletedPackages.Num(), Confirmed.Num(), ObjectsDeleted);
return Outcome;
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,46 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
/** What the delete window ended up doing. Everything the panel needs to report afterwards. */
struct FAssetDeleteOutcome
{
/** Packages handed to the window. */
int32 Requested = 0;
/** Refused by policy - levels, OFPA packages, engine content. Never reaches the engine. */
int32 Refused = 0;
/** Ticked and passed to ObjectTools. */
int32 Attempted = 0;
/**
* Packages the registry can no longer find afterwards.
*
* Measured rather than taken from the engine's return value: ObjectTools reports a count of
* deleted *objects*, and one package can hold several. Asking the registry what is actually
* gone is the only figure that matches what the user will see in the Content Browser.
*/
TArray<FName> DeletedPackages;
/** True when the user closed or cancelled the window; nothing was touched. */
bool bCancelled = true;
};
/**
* Show the delete window: what is about to be destroyed, what still points at it, and why some
* rows are refused.
*
* Modal, and deliberately heavier than the export window. Export is reversible by deleting the
* output folder; this is not, and the window's job is to be read rather than clicked through -
* hence the per-row referencer count, the explicit acknowledgement, and Cancel as the default.
*
* The window does not delete anything itself. Once confirmed it hands the assets to
* ObjectTools::DeleteAssets with the engine's own confirmation enabled, so the familiar Delete
* Assets dialog - referencer list, Force Delete, Replace References - appears exactly as it does
* from the Content Browser. Reimplementing that would mean reimplementing reference replacement,
* and getting it subtly wrong on someone's project.
*/
FAssetDeleteOutcome ShowAssetDeleteDialog(const TArray<FName>& Packages);
@@ -12,6 +12,7 @@
#include "AssetUsageExporter.h"
#include "AssetUsagePaths.h"
#include "AssetUsageReportWriter.h"
#include "SAssetDeleteDialog.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Algo/AllOf.h"
@@ -580,6 +581,33 @@ void SAssetUsageAuditPanel::Construct(const FArguments& InArgs)
.OnClicked(this, &SAssetUsageAuditPanel::OnExportFilesClicked)
.IsEnabled(this, &SAssetUsageAuditPanel::HasCheckedAssets)
]
// Delete sits last and behind a separator, deliberately. It acts on the same ticked
// set as Export, so it belongs on this bar, but it is the only irreversible action in
// the tool and must never be the button someone reaches by muscle memory.
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
.Padding(8.0f, 2.0f, 2.0f, 2.0f)
[
SNew(SSeparator)
.Orientation(Orient_Vertical)
]
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
.Padding(2.0f, 0.0f)
[
SNew(SButton)
.Text(LOCTEXT("DeleteTicked", "Delete ticked assets..."))
.ToolTipText(LOCTEXT("DeleteTickedTip",
"Permanently delete the ticked assets from the project. Levels are never deleted. "
"Shows what still references them before anything is removed."))
.ButtonColorAndOpacity(FLinearColor(0.55f, 0.16f, 0.16f))
.OnClicked(this, &SAssetUsageAuditPanel::OnDeleteClicked)
.IsEnabled(this, &SAssetUsageAuditPanel::HasCheckedAssets)
]
]
];
}
@@ -2224,6 +2252,83 @@ FReply SAssetUsageAuditPanel::OnExportFilesClicked()
return FReply::Handled();
}
// --- Deleting ----------------------------------------------------------------------------------
FReply SAssetUsageAuditPanel::OnDeleteClicked()
{
TArray<FAssetUsageRow> Rows;
GetCheckedRows(Rows);
if (Rows.IsEmpty())
{
return FReply::Handled();
}
TArray<FName> Packages;
Packages.Reserve(Rows.Num());
for (const FAssetUsageRow& Row : Rows)
{
Packages.Add(Row.PackageName);
}
// Deliberately NOT widened with the dependency closure, unlike export. Export widens so that a
// mesh arrives with its materials; widening a delete the same way would destroy content nobody
// ticked, and a single shared texture would drag half the project along with it. Delete acts on
// exactly what was ticked, and never on more.
const FAssetDeleteOutcome Outcome = ShowAssetDeleteDialog(Packages);
if (Outcome.bCancelled)
{
StatusText = LOCTEXT("DeleteCancelled", "Delete cancelled - nothing was changed.");
return FReply::Handled();
}
ForgetDeletedPackages(Outcome.DeletedPackages);
StatusText = FText::Format(
LOCTEXT("DeleteFinished",
"Deleted {0} of {1} ticked asset(s); {2} were refused. Reference counts elsewhere in this result are now out of date - run the audit again."),
FText::AsNumber(Outcome.DeletedPackages.Num()),
FText::AsNumber(Outcome.Requested),
FText::AsNumber(Outcome.Refused));
return FReply::Handled();
}
void SAssetUsageAuditPanel::ForgetDeletedPackages(const TArray<FName>& DeletedPackages)
{
if (DeletedPackages.IsEmpty())
{
return;
}
const TSet<FName> Gone(DeletedPackages);
LastResult.Rows.RemoveAll([&Gone](const FAssetUsageRow& Row)
{
return Gone.Contains(Row.PackageName);
});
AllRows.RemoveAll([&Gone](const FAssetUsageRowPtr& Row)
{
return Row.IsValid() && Gone.Contains(Row->PackageName);
});
for (FName Package : DeletedPackages)
{
CheckedPackages.Remove(Package);
}
// Removing the rows is honest but incomplete: everything that referenced them still carries the
// reference counts it had before, and an asset that was UsedByAssetsOnly through a now-deleted
// Blueprint has a different verdict than the one on screen. Marking the result stale says so,
// rather than leaving figures that look freshly measured.
bResultStale = true;
RebuildFilteredRows();
}
void SAssetUsageAuditPanel::RunMigrateExport(const TArray<FAssetUsageRow>& Rows, const FAssetExportRequest& Request)
{
TArray<FName> Packages;
@@ -278,6 +278,31 @@ private:
FReply OnExportFilesClicked();
bool HasCheckedAssets() const;
// --- Deleting ----------------------------------------------------------------------------
/**
* Delete the ticked assets, after two confirmations.
*
* Sits beside Export because it acts on the same ticked set, and is the reason the audit is
* worth running: the point of finding unused content is to be able to remove it. It is kept
* visually apart and never becomes the default action.
*
* Levels are refused outright - see AssetDeletionScan::ERefusal::IsLevel. Everything else the
* window explains before anything is touched, and the destruction itself is ObjectTools' code,
* not ours.
*/
FReply OnDeleteClicked();
/**
* Drop deleted packages from the result without re-running the sweep.
*
* A full re-run after a delete would cost minutes on this project, and the rows that remain are
* still correct - what changed is that some assets are gone, plus the referencer counts of
* whatever pointed at them. The result is marked stale so the numbers are not mistaken for a
* fresh sweep.
*/
void ForgetDeletedPackages(const TArray<FName>& DeletedPackages);
/** Byte copy of the package files, expanding levels to their OFPA packages. */
void RunCopyExport(const TArray<FAssetUsageRow>& Rows, const FAssetExportRequest& Request);