diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/VisualElements.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/VisualElements.meta
new file mode 100644
index 00000000..5164f028
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/VisualElements.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 025a6e9308bb74e3eb411ce8b498c6e7
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/VisualElements/AspidAnimatedDotsBackgroundStatusTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/VisualElements/AspidAnimatedDotsBackgroundStatusTests.cs
new file mode 100644
index 00000000..d9bf0bfd
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/VisualElements/AspidAnimatedDotsBackgroundStatusTests.cs
@@ -0,0 +1,98 @@
+using UnityEditor;
+using UnityEngine;
+using NUnit.Framework;
+using System.Collections;
+using UnityEngine.TestTools;
+
+namespace Aspid.FastTools.UIElements.Editors.Internal.Tests
+{
+ ///
+ /// Behavioural coverage for the dots canvas' status wash: a status class must repaint all three blobs one flat
+ /// tone through USS, and dropping it must bring the default three-tone signal gradient back.
+ ///
+ ///
+ /// The round trip is the point. The wash used to be applied as inline colors, which latched the blobs out of USS
+ /// resolution for good and forced the "restore the gradient" path to re-supply the palette values from C#.
+ /// Asserted structurally (blobs equal / distinct, channel ordering) rather than against literal rgb values —
+ /// pinning the numbers here would re-create exactly the palette duplication this covers.
+ ///
+ [TestFixture]
+ internal sealed class AspidAnimatedDotsBackgroundStatusTests
+ {
+ private EditorWindow _window;
+ private AspidAnimatedDotsBackground _canvas;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _window = ScriptableObject.CreateInstance();
+ _window.ShowUtility();
+
+ // The default gradient resolves through the shared palette, so the host needs the theme sheets — the
+ // status washes themselves are component-scoped and come with the canvas' own stylesheet.
+ _window.rootVisualElement.AddAspidThemeStyleSheets();
+
+ _canvas = new AspidAnimatedDotsBackground();
+ _window.rootVisualElement.Add(_canvas);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (_window) Object.DestroyImmediate(_window);
+ }
+
+ [UnityTest]
+ public IEnumerator None_ResolvesTheThreeToneSignalGradient()
+ {
+ yield return null;
+
+ Assert.AreNotEqual(_canvas.Color1, _canvas.Color2, "The default canvas must keep its three distinct signal blobs.");
+ Assert.AreNotEqual(_canvas.Color2, _canvas.Color3, "The default canvas must keep its three distinct signal blobs.");
+ }
+
+ [UnityTest]
+ public IEnumerator Status_PaintsEveryBlobTheSameWash()
+ {
+ _canvas.Status = StatusStyle.Type.Warning;
+ yield return null;
+
+ Assert.AreEqual(_canvas.Color1, _canvas.Color2, "A status wash must paint every blob the one tone.");
+ Assert.AreEqual(_canvas.Color2, _canvas.Color3, "A status wash must paint every blob the one tone.");
+
+ var wash = _canvas.Color1;
+ Assert.Greater(wash.r, wash.g, "The warning wash must read amber — red over green over blue.");
+ Assert.Greater(wash.g, wash.b, "The warning wash must read amber — red over green over blue.");
+ }
+
+ [UnityTest]
+ public IEnumerator Status_SwapsBetweenWashes()
+ {
+ _canvas.Status = StatusStyle.Type.Warning;
+ yield return null;
+
+ var warning = _canvas.Color1;
+
+ _canvas.Status = StatusStyle.Type.Success;
+ yield return null;
+
+ Assert.AreNotEqual(warning, _canvas.Color1, "Switching status must repaint the wash.");
+ Assert.Greater(_canvas.Color1.g, _canvas.Color1.r, "The success wash must read green.");
+ }
+
+ [UnityTest]
+ public IEnumerator None_RestoresTheGradientAfterAWash()
+ {
+ _canvas.Status = StatusStyle.Type.Warning;
+ yield return null;
+
+ Assert.AreEqual(_canvas.Color1, _canvas.Color2, "Precondition: the wash is on.");
+
+ _canvas.Status = StatusStyle.Type.None;
+ yield return null;
+
+ Assert.AreNotEqual(_canvas.Color1, _canvas.Color2, "Dropping the status must hand the blobs back to the USS gradient.");
+ Assert.AreNotEqual(_canvas.Color2, _canvas.Color3, "Dropping the status must hand the blobs back to the USS gradient.");
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/VisualElements/AspidAnimatedDotsBackgroundStatusTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/VisualElements/AspidAnimatedDotsBackgroundStatusTests.cs.meta
new file mode 100644
index 00000000..ad610a87
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/VisualElements/AspidAnimatedDotsBackgroundStatusTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4c4915f8293a944fa8d58e70608f2a62
\ No newline at end of file
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Components/Aspid-FastTools-AspidAnimatedDotsBackground.uss b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Components/Aspid-FastTools-AspidAnimatedDotsBackground.uss
index 8717fe6c..c28274ae 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Components/Aspid-FastTools-AspidAnimatedDotsBackground.uss
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Components/Aspid-FastTools-AspidAnimatedDotsBackground.uss
@@ -9,7 +9,42 @@
--aspid-fasttools-colors-dot_blob-color_2: var(--aspid-colors-status-warning-text-dark);
--aspid-fasttools-colors-dot_blob-color_3: var(--aspid-colors-status-error-text-dark);
+ /* Status washes: dim, desaturated hues a status class paints across all three blobs at once, so the canvas
+ reads as one calm state wash instead of the default good→bad gradient. Component-scoped — the palette's
+ status families are tuned for text and borders and read far too saturated behind a whole window. */
+ --aspid-fasttools-colors-dot_blob-info: rgb(38, 54, 77);
+ --aspid-fasttools-colors-dot_blob-success: rgb(41, 71, 54);
+ --aspid-fasttools-colors-dot_blob-warning: rgb(77, 64, 31);
+ --aspid-fasttools-colors-dot_blob-error: rgb(82, 41, 41);
+
--aspid-fasttools-metrics-dot_radius: 1.55;
--aspid-fasttools-metrics-dot_spacing: 18;
--aspid-fasttools-metrics-dot_scale_reference: 420;
}
+
+/* Declared after :root deliberately — a status class and :root carry the same specificity, so source order is what
+ lets a status win over the default gradient. Dropping the class falls back to :root, which is how StatusStyle.Type.None
+ restores the three-tone signal look. */
+.aspid-fasttools-status--info {
+ --aspid-fasttools-colors-dot_blob-color_1: var(--aspid-fasttools-colors-dot_blob-info);
+ --aspid-fasttools-colors-dot_blob-color_2: var(--aspid-fasttools-colors-dot_blob-info);
+ --aspid-fasttools-colors-dot_blob-color_3: var(--aspid-fasttools-colors-dot_blob-info);
+}
+
+.aspid-fasttools-status--success {
+ --aspid-fasttools-colors-dot_blob-color_1: var(--aspid-fasttools-colors-dot_blob-success);
+ --aspid-fasttools-colors-dot_blob-color_2: var(--aspid-fasttools-colors-dot_blob-success);
+ --aspid-fasttools-colors-dot_blob-color_3: var(--aspid-fasttools-colors-dot_blob-success);
+}
+
+.aspid-fasttools-status--warning {
+ --aspid-fasttools-colors-dot_blob-color_1: var(--aspid-fasttools-colors-dot_blob-warning);
+ --aspid-fasttools-colors-dot_blob-color_2: var(--aspid-fasttools-colors-dot_blob-warning);
+ --aspid-fasttools-colors-dot_blob-color_3: var(--aspid-fasttools-colors-dot_blob-warning);
+}
+
+.aspid-fasttools-status--error {
+ --aspid-fasttools-colors-dot_blob-color_1: var(--aspid-fasttools-colors-dot_blob-error);
+ --aspid-fasttools-colors-dot_blob-color_2: var(--aspid-fasttools-colors-dot_blob-error);
+ --aspid-fasttools-colors-dot_blob-color_3: var(--aspid-fasttools-colors-dot_blob-error);
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing.meta
new file mode 100644
index 00000000..01746138
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 844dbaa4618b4ed1921664d978217b59
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/MissingReferenceGroup.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/MissingReferenceGroup.cs
new file mode 100644
index 00000000..0ab86b86
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/MissingReferenceGroup.cs
@@ -0,0 +1,162 @@
+using System;
+using UnityEditor;
+using System.Linq;
+using System.Collections.Generic;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ /// One broken managed-reference entry plus the asset it lives in.
+ internal readonly struct MissingReferenceLocation
+ {
+ public readonly string AssetPath;
+ public readonly MissingReferenceEntry Entry;
+
+ public MissingReferenceLocation(string assetPath, MissingReferenceEntry entry)
+ {
+ AssetPath = assetPath;
+ Entry = entry;
+ }
+ }
+
+ ///
+ /// Every broken reference sharing one stored type across the project — the unit the Project References audit lists
+ /// and bulk-fixes. Resolves a single picker constraint by intersecting the entries' declared field types, falling
+ /// back to when they disagree.
+ ///
+ internal sealed class MissingReferenceGroup
+ {
+ public readonly ManagedTypeName StoredType;
+ public readonly List Entries = new();
+
+ private readonly HashSet _files = new(StringComparer.Ordinal);
+ private readonly SerializeReferenceConstraintCache _constraints = new();
+
+ public MissingReferenceGroup(ManagedTypeName storedType)
+ {
+ StoredType = storedType;
+ }
+
+ public int FileCount => _files.Count;
+
+ public string DisplayName => StoredType.DisplayName;
+
+ ///
+ /// Groups every unresolved managed reference in the project by stored type, backed by the shared usage index,
+ /// biggest group first. Cheap once the index is warm — it is an in-memory filter, not a sweep.
+ ///
+ public static List CollectFromIndex()
+ {
+ var byType = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var usage in SerializeReferenceTypeUsageIndex.EnumerateUnresolved())
+ {
+ var path = AssetDatabase.GUIDToAssetPath(usage.Guid);
+ if (string.IsNullOrEmpty(path)) continue;
+
+ var key = SerializeReferenceHelpers.StoredTypeKey(usage.StoredType);
+ if (!byType.TryGetValue(key, out var group))
+ {
+ group = new MissingReferenceGroup(usage.StoredType);
+ byType.Add(key, group);
+ }
+
+ group.Add(path, new MissingReferenceEntry(usage.FileId, usage.Rid, usage.StoredType));
+ }
+
+ var groups = byType.Values.ToList();
+ groups.Sort((a, b) => b.Entries.Count.CompareTo(a.Entries.Count));
+ return groups;
+ }
+
+ public void Add(string assetPath, MissingReferenceEntry entry)
+ {
+ Entries.Add(new MissingReferenceLocation(assetPath, entry));
+ _files.Add(assetPath);
+ }
+
+ ///
+ /// The ranked Smart Fix for this group's broken type, or when nothing clears the
+ /// confidence threshold.
+ ///
+ ///
+ /// Ranked against the constraint-filtered pool, so the suggestion is always assignable — which is what lets a
+ /// quick-apply bypass the picker. The field names come from the first entry: every entry in a group stores the
+ /// same broken type, so any of them ranks the same candidates.
+ ///
+ public bool TryGetSuggestion(Type constraint, out SerializeReferenceRepairSuggestions.RepairCandidate suggestion)
+ {
+ suggestion = default;
+
+ var first = Entries[0];
+ var fieldNames = SerializeReferenceYamlEditor.GetReferenceFieldNames(first.AssetPath, first.Entry.FileId, first.Entry.Rid);
+
+ var ranked = SerializeReferenceRepairSuggestions.Rank(StoredType, fieldNames, constraint);
+ if (ranked.Count == 0) return false;
+
+ suggestion = ranked[0];
+ return true;
+ }
+
+ /// The type every entry's field can hold, or when that cannot be narrowed.
+ /// Per-file constraint maps are built once and cached, so the intersection costs one scan per distinct asset.
+ public Type ResolveConstraint() => ResolveConstraint(out _);
+
+ ///
+ ///
+ /// Whether the fallback came from the field types disagreeing (vs. one being
+ /// unrecoverable) — the bulk-fix confirmation warns on that case.
+ ///
+ public Type ResolveConstraint(out bool mixedFieldTypes)
+ {
+ mixedFieldTypes = false;
+ Type common = null;
+
+ foreach (var entry in Entries)
+ {
+ // A field type we cannot recover (a reference nested in a missing parent, or an orphaned rid no
+ // field points at) leaves the group unconstrained — a tighter guess could hide a valid pick.
+ var fieldType = _constraints.Resolve(entry.AssetPath, entry.Entry.FileId, entry.Entry.Rid);
+ if (fieldType is null) return typeof(object);
+
+ if (common is null)
+ {
+ common = fieldType;
+ }
+ else if (common != fieldType)
+ {
+ mixedFieldTypes = true;
+ return typeof(object);
+ }
+ }
+
+ return common ?? typeof(object);
+ }
+ }
+
+ ///
+ /// A group's picker constraint and whether it reads as a one-click [MovedFrom] migration, resolved once so
+ /// the audit's partition, card body and picker label share one computation and can never disagree.
+ ///
+ ///
+ /// A migration is an authoritative [MovedFrom] rename whose target also fits the group's field constraint —
+ /// Migrate all bypasses the picker's assignability guarantee, and an incompatible target would be nulled by
+ /// Unity at load, so the constraint gate matters.
+ ///
+ internal readonly struct MissingReferenceMigration
+ {
+ public readonly Type Constraint;
+ public readonly bool IsMigration;
+
+ /// The [MovedFrom] target when ; otherwise .
+ public readonly Type Target;
+
+ public MissingReferenceMigration(MissingReferenceGroup group)
+ {
+ Constraint = group.ResolveConstraint();
+ IsMigration = SerializeReferenceMovedFromResolver.TryResolve(group.StoredType, out var target) &&
+ (Constraint == typeof(object) || Constraint.IsAssignableFrom(target));
+ Target = IsMigration ? target : null;
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceCanvasStyle.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/MissingReferenceGroup.cs.meta
similarity index 86%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceCanvasStyle.cs.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/MissingReferenceGroup.cs.meta
index 557dfcdb..6f41c71e 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceCanvasStyle.cs.meta
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/MissingReferenceGroup.cs.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 88c7fe95eeec47a981a9fbd7c8a00133
+guid: c642574d5a0f4068966de824e7d76f70
MonoImporter:
externalObjects: {}
serializedVersion: 2
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceBatchEditor.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceBatchEditor.cs
new file mode 100644
index 00000000..41e603bb
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceBatchEditor.cs
@@ -0,0 +1,161 @@
+using System;
+using UnityEditor;
+using System.Linq;
+using System.Collections.Generic;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ ///
+ /// The bulk half of the repair tooling: rewriting or nulling many managed-reference entries at once, batched per
+ /// file so each affected asset is reimported exactly once. Pure file work — the confirmations, receipts and the
+ /// result rendering belong to the caller.
+ ///
+ ///
+ /// Every batch runs inside , which defers each
+ /// to one pass at the end, behind a
+ /// cancel-free progress bar. Entries whose write fails are skipped, so the returned count is what actually
+ /// changed on disk, not what was asked for.
+ ///
+ internal static class SerializeReferenceBatchEditor
+ {
+ ///
+ /// Splits entries into those safe to rewrite on disk and those open in Prefab Mode / a loaded scene, which
+ /// must be repaired in memory instead.
+ ///
+ public static void SplitWritable(IReadOnlyList source,
+ out List onDisk, out List inMemory)
+ {
+ var prefabStagePath = SerializeReferenceOpenCopyGuard.CurrentPrefabStagePath();
+ onDisk = new List(source.Count);
+ inMemory = new List();
+
+ foreach (var entry in source)
+ {
+ if (SerializeReferenceOpenCopyGuard.IsWritable(entry.AssetPath, prefabStagePath)) onDisk.Add(entry);
+ else inMemory.Add(entry);
+ }
+ }
+
+ ///
+ /// The entries safe to write, reporting through how many were held back because an
+ /// open in-memory copy would clobber the file edit on its next save.
+ ///
+ public static List FilterWritable(IReadOnlyList source, out int skipped)
+ {
+ var prefabStagePath = SerializeReferenceOpenCopyGuard.CurrentPrefabStagePath();
+ var writable = new List(source.Count);
+ skipped = 0;
+
+ foreach (var entry in source)
+ {
+ if (SerializeReferenceOpenCopyGuard.IsWritable(entry.AssetPath, prefabStagePath)) writable.Add(entry);
+ else skipped++;
+ }
+
+ return writable;
+ }
+
+ ///
+ /// The entries that still store , i.e. the ones a receipt for that fix may
+ /// safely revert; counts the rest.
+ ///
+ ///
+ /// A group can have been re-broken and fixed to a DIFFERENT type since the receipt was written, and blindly
+ /// rewriting would destroy that newer fix. "Still holds it" is tested as a rewrite towards the applied type
+ /// whose old line already equals its new line.
+ ///
+ public static List FilterStillHolding(IReadOnlyList source,
+ ManagedTypeName appliedType, out int diverged)
+ {
+ var holding = new List(source.Count);
+ diverged = 0;
+
+ foreach (var entry in source)
+ {
+ if (SerializeReferenceYamlEditor.TryComputeRewrite(entry.AssetPath, entry.Entry.FileId, entry.Entry.Rid, appliedType, out var edit) &&
+ edit.IsValid && string.Equals(edit.OldLine, edit.NewLine, StringComparison.Ordinal))
+ holding.Add(entry);
+ else
+ diverged++;
+ }
+
+ return holding;
+ }
+
+ /// Rewrites every entry's stored type to ; returns how many were written.
+ public static int Rewrite(IReadOnlyList entries, ManagedTypeName targetType, string progressTitle) =>
+ RunBatch(entries, progressTitle, (path, entry) =>
+ SerializeReferenceYamlEditor.TryRewriteType(path, entry.Entry.FileId, entry.Entry.Rid, targetType));
+
+ ///
+ /// Nulls every entry to the null managed-reference id and drops its payload; returns how many were cleared.
+ ///
+ public static int Null(IReadOnlyList entries, string progressTitle) =>
+ RunBatch(entries, progressTitle, (path, entry) =>
+ SerializeReferenceYamlEditor.TryNullReference(path, entry.Entry.FileId, entry.Entry.Rid));
+
+ ///
+ /// Nulls each open entry on its live object — the file rewrite is skipped for open assets, so these stay in
+ /// the audit until the asset is saved. Returns how many were cleared.
+ ///
+ public static int ClearOpenInMemory(IReadOnlyList entries, ManagedTypeName storedType)
+ {
+ var cleared = 0;
+ foreach (var entry in entries)
+ {
+ if (SerializeReferenceHelpers.TryClearMissingReferenceInMemory(entry.AssetPath, entry.Entry.Rid, storedType))
+ cleared++;
+ }
+
+ return cleared;
+ }
+
+ /// How many distinct files spans.
+ public static int CountFiles(IEnumerable entries) =>
+ entries.Select(entry => entry.AssetPath).Distinct(StringComparer.Ordinal).Count();
+
+ // The shared per-file loop behind Rewrite / Null: only the per-entry edit differs. A file is reimported only
+ // when at least one of its entries actually changed.
+ private static int RunBatch(IReadOnlyList entries, string progressTitle,
+ Func edit)
+ {
+ var byFile = entries
+ .GroupBy(entry => entry.AssetPath, StringComparer.Ordinal)
+ .ToArray();
+
+ var applied = 0;
+
+ AssetDatabase.StartAssetEditing();
+ try
+ {
+ for (var i = 0; i < byFile.Length; i++)
+ {
+ var file = byFile[i];
+ EditorUtility.DisplayProgressBar(
+ progressTitle,
+ $"{file.Key} ({i + 1}/{byFile.Length})",
+ (float)i / byFile.Length);
+
+ var changed = false;
+ foreach (var entry in file)
+ {
+ if (!edit(file.Key, entry)) continue;
+
+ applied++;
+ changed = true;
+ }
+
+ if (changed) AssetDatabase.ImportAsset(file.Key, ImportAssetOptions.ForceUpdate);
+ }
+ }
+ finally
+ {
+ AssetDatabase.StopAssetEditing();
+ EditorUtility.ClearProgressBar();
+ }
+
+ return applied;
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceBatchEditor.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceBatchEditor.cs.meta
new file mode 100644
index 00000000..3d7eea5d
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceBatchEditor.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 5a5b22da287c42efb6aab1e385f39641
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceConstraintCache.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceConstraintCache.cs
new file mode 100644
index 00000000..acf0ca84
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceConstraintCache.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ ///
+ /// Per-asset memo over : the declared field type backing
+ /// each (fileId, rid) , so the many lookups a repair surface makes cost one scan per distinct asset.
+ ///
+ ///
+ /// Building one map is a LoadAllAssetsAtPath plus a full SerializedObject walk, so every picker open
+ /// must not re-scan. The flip side is staleness: after any edit that rewrote the YAML, or the
+ /// next lookup answers from the pre-edit file.
+ ///
+ internal sealed class SerializeReferenceConstraintCache
+ {
+ private readonly Dictionary> _maps = new(StringComparer.Ordinal);
+
+ ///
+ /// The declared field type backing , or (unconstrained) for an
+ /// orphaned payload or an unresolvable field type.
+ ///
+ /// Keyed by exact (fileId, rid) since rids collide across documents.
+ public Type Resolve(string assetPath, long fileId, long rid)
+ {
+ if (!_maps.TryGetValue(assetPath, out var map))
+ {
+ map = SerializeReferenceHelpers.BuildConstraintMap(assetPath);
+ _maps[assetPath] = map;
+ }
+
+ return map.GetValueOrDefault((fileId, rid));
+ }
+
+ /// Drops every memoised map so the next lookup re-reads the (possibly rewritten) files.
+ public void Clear() => _maps.Clear();
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceConstraintCache.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceConstraintCache.cs.meta
new file mode 100644
index 00000000..4025ad97
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceConstraintCache.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 784f749f85a34c3198364bd74c05cf78
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceGraphEditor.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceGraphEditor.cs
new file mode 100644
index 00000000..a671443e
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceGraphEditor.cs
@@ -0,0 +1,309 @@
+using System;
+using UnityEngine;
+using UnityEditor;
+using Aspid.FastTools.Editors;
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+using Object = UnityEngine.Object;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ ///
+ /// Every single-entry repair the Asset References graph offers, without any of its UI: assigning, re-pointing and
+ /// clearing one managed reference, and dropping one orphaned payload.
+ ///
+ ///
+ ///
+ /// Two edit routes, picked by what the entry is rather than by the caller. A healthy or empty slot goes through
+ /// ( ), so Unity creates, rewrites or
+ /// removes the RefIds entry exactly as the Inspector would. A missing reference cannot be
+ /// reassigned through that API at all, so it is edited by rewriting the YAML in place
+ /// ( / / ) — which is also why those
+ /// three confirm first, cannot be undone through Unity's undo stack, and refuse to run against an asset whose open
+ /// in-memory copy would clobber the write (see ).
+ ///
+ ///
+ /// Each entry point reports whether anything actually changed; re-rendering the graph afterwards is the caller's
+ /// concern.
+ ///
+ ///
+ internal static class SerializeReferenceGraphEditor
+ {
+ ///
+ /// Re-points a missing reference at by rewriting the stored type name
+ /// in the YAML, keeping the orphaned payload. An empty name clears the reference instead (see
+ /// ). Returns whether the file changed.
+ ///
+ public static bool ApplyFix(string assetPath, long fileId, long rid, string assemblyQualifiedName)
+ {
+ // emits an empty name: clear the reference (dropping the broken payload) rather than letting it
+ // fall through to the null-type guard below as a silent no-op.
+ if (string.IsNullOrEmpty(assemblyQualifiedName)) return ClearReference(assetPath, fileId, rid);
+
+ if (SerializeReferenceOpenCopyGuard.BlockedByOpenCopy(assetPath)) return false;
+
+ var type = Type.GetType(assemblyQualifiedName, throwOnError: false);
+ if (type is null) return false;
+
+ // Rewrite only the captured file id's document: a rid is unique within a document but can collide across
+ // documents, so looping the asset's documents could rewrite a healthy reference that shares the rid.
+ if (!SerializeReferenceYamlEditor.TryRewriteType(assetPath, fileId, rid, ManagedTypeName.FromType(type)))
+ return false;
+
+ AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
+ SerializeReferenceRepairSuggestions.ClearCache();
+ return true;
+ }
+
+ ///
+ /// Resets a missing reference to <None> in the YAML: nulls every pointer to Unity's null sentinel
+ /// (-2) and drops the RefIds entry — exactly what Unity writes for a cleared field. Confirmed and not
+ /// undoable; the broken payload is discarded. Returns whether the file changed.
+ ///
+ public static bool ClearReference(string assetPath, long fileId, long rid)
+ {
+ if (SerializeReferenceOpenCopyGuard.BlockedByOpenCopy(assetPath)) return false;
+
+ // Name how many fields the clear will null so an aliased reference doesn't silently take down siblings.
+ // A non-positive count means the pointers couldn't be located — use the unnumbered wording, not "0 fields".
+ var fieldCount = SerializeReferenceYamlEditor.CountPointersTo(assetPath, fileId, rid);
+ var pointerLine = fieldCount switch
+ {
+ 1 => "This nulls the 1 field pointing at it",
+ > 1 => $"This reference is aliased across {fieldCount} fields — clearing it nulls every one of them",
+ _ => "This nulls every field pointing at it",
+ };
+
+ if (!EditorUtility.DisplayDialog(
+ "Clear Reference",
+ $"Reset this managed reference (rid {rid}) to in\n{assetPath}?\n\n" +
+ $"{pointerLine} and discards its stored data. It edits the asset file directly and cannot be undone.",
+ "Clear", "Cancel"))
+ return false;
+
+ if (!SerializeReferenceYamlEditor.TryNullReference(assetPath, fileId, rid)) return false;
+
+ // The forced import lets the index invalidator patch this one asset surgically — a full ClearCache here
+ // would dump the whole warm index and put Project References back on its modal first-scan.
+ AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
+ SerializeReferenceRepairSuggestions.ClearCache();
+ return true;
+ }
+
+ ///
+ /// Drops a dangling RefIds entry no field points at, after confirming. Returns whether the file changed.
+ ///
+ ///
+ /// The fresh scan proving the rid is no longer an orphan, when the on-screen graph turned out to be stale;
+ /// otherwise. Re-render from it rather than reading the unchanged file a second time.
+ ///
+ public static bool TryClearOrphan(string assetPath, long fileId, long rid, out List staleRescan)
+ {
+ staleRescan = null;
+
+ if (SerializeReferenceOpenCopyGuard.BlockedByOpenCopy(assetPath)) return false;
+
+ if (!EditorUtility.DisplayDialog(
+ "Drop Orphaned Entry",
+ $"Remove the orphaned managed-reference entry (rid {rid}) from\n{assetPath}?\n\n" +
+ "This edits the asset file directly and cannot be undone.",
+ "Remove", "Cancel"))
+ return false;
+
+ // Guard against a stale graph: confirm the rid is still an orphan against a fresh scan before deleting.
+ var fresh = SerializeReferenceGraphScanner.Build(assetPath);
+ foreach (var document in fresh)
+ {
+ if (document.FileId != fileId || !document.Orphans.Contains(rid)) continue;
+
+ if (!SerializeReferenceYamlEditor.TryRemoveEntry(assetPath, fileId, rid)) return false;
+
+ // Surgical index patch via the import invalidator, not a full ClearCache (see ClearReference).
+ AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
+ SerializeReferenceRepairSuggestions.ClearCache();
+ return true;
+ }
+
+ staleRescan = fresh;
+ return false;
+ }
+
+ ///
+ /// Edits a healthy or empty slot through , so Unity
+ /// creates / rewrites / removes the RefIds entry exactly as the Inspector would. An empty name clears
+ /// the slot to <None> . Returns whether the asset changed.
+ ///
+ ///
+ /// The asset is saved to disk so the disk-read graph reflects the edit on rescan; a path the API cannot reach
+ /// is reported through a dialog and skipped.
+ ///
+ public static bool ApplyLive(string assetPath, long fileId, string graphPath, string assemblyQualifiedName)
+ {
+ var type = string.IsNullOrEmpty(assemblyQualifiedName)
+ ? null
+ : Type.GetType(assemblyQualifiedName, throwOnError: false);
+
+ // A non-empty name that fails to load is an unresolved pick, not a clear — leave the slot untouched rather
+ // than silently nulling it.
+ if (!string.IsNullOrEmpty(assemblyQualifiedName) && type is null) return false;
+
+ if (!TryResolveLiveProperty(assetPath, fileId, graphPath, out var serializedObject, out var property))
+ {
+ EditorUtility.DisplayDialog(
+ "Edit Reference",
+ "This slot cannot be edited here — its field is not reachable through the serialization API " +
+ "(it may be an orphan, live in a scene, or sit under a missing parent). Edit it in the Inspector " +
+ "or repair its parent first.",
+ "OK");
+ return false;
+ }
+
+ using (serializedObject)
+ {
+ var previous = property.managedReferenceValue;
+ // type == null clears to ; a concrete type carries over the previous value's matching fields.
+ property.SetManagedReferenceAndApply(SerializeReferenceHelpers.CreateInstancePreservingData(type, previous));
+ property.isExpanded = type is not null;
+
+ var target = serializedObject.targetObject;
+ EditorUtility.SetDirty(target);
+ PersistEdit(assetPath, target);
+ }
+
+ // PersistEdit's save triggers the import that lets the index invalidator patch this asset surgically —
+ // no full ClearCache (see ClearReference).
+ SerializeReferenceRepairSuggestions.ClearCache();
+ SerializeReferenceYamlProbeCache.ClearCache();
+ return true;
+ }
+
+ ///
+ /// Writes an assembly-qualified type name into the backing string of a required [TypeSelector] field —
+ /// the one required shape the managed-reference routes above cannot reach, since a string /
+ /// SerializableType field is never threaded into RefIds . Returns whether the asset changed.
+ ///
+ public static bool ApplyRequiredString(GateViolation violation, string assemblyQualifiedName)
+ {
+ // A non-empty name that fails to load is an unresolved pick, not a clear — leave the field untouched.
+ // (empty) writes an empty name: for a required field that just keeps the violation visible.
+ if (!string.IsNullOrEmpty(assemblyQualifiedName) &&
+ Type.GetType(assemblyQualifiedName, throwOnError: false) is null)
+ return false;
+
+ if (!TryResolveRequiredStringProperty(violation, out var serializedObject, out var property))
+ {
+ EditorUtility.DisplayDialog(
+ "Assign Required Type",
+ "This field cannot be edited here — it is not reachable through the serialization API. " +
+ "Edit it in the Inspector instead.",
+ "OK");
+ return false;
+ }
+
+ using (serializedObject)
+ {
+ property.SetStringAndApply(assemblyQualifiedName ?? string.Empty);
+
+ var target = serializedObject.targetObject;
+ EditorUtility.SetDirty(target);
+ PersistEdit(violation.AssetPath, target);
+ }
+
+ SerializeReferenceYamlProbeCache.ClearCache();
+ return true;
+ }
+
+ ///
+ /// Resolves the live document at and the managed-reference property at
+ /// . The caller disposes the returned .
+ ///
+ ///
+ /// for a path the API cannot reach — an empty path, a scene asset, or a field under a
+ /// missing / null parent.
+ ///
+ public static bool TryResolveLiveProperty(string assetPath, long fileId, string graphPath,
+ out SerializedObject serializedObject, out SerializedProperty property)
+ {
+ serializedObject = null;
+ property = null;
+
+ if (string.IsNullOrEmpty(graphPath)) return false;
+ // Scenes are not loadable through LoadAllAssetsAtPath (see SerializeReferenceHelpers.IsScene).
+ if (SerializeReferenceHelpers.IsScene(assetPath)) return false;
+
+ return TryResolveProperty(assetPath, fileId, ToSerializedPropertyPath(graphPath),
+ SerializedPropertyType.ManagedReference, out serializedObject, out property);
+ }
+
+ ///
+ /// Resolves the live document at the violation's file id and the string property at its field path. The caller
+ /// disposes the returned .
+ ///
+ ///
+ /// The violation's field path is already a path (the gate scanner records
+ /// iterator.propertyPath verbatim), so unlike no graph-path
+ /// conversion applies. Returns for a scene asset (not object-loadable).
+ ///
+ public static bool TryResolveRequiredStringProperty(GateViolation violation,
+ out SerializedObject serializedObject, out SerializedProperty property)
+ {
+ serializedObject = null;
+ property = null;
+
+ if (SerializeReferenceHelpers.IsScene(violation.AssetPath)) return false;
+
+ return TryResolveProperty(violation.AssetPath, violation.FileId, violation.FieldPath,
+ SerializedPropertyType.String, out serializedObject, out property);
+ }
+
+ ///
+ /// Converts a graph field path's "name[i]" list indices into Unity's "name.Array.data[i]" form —
+ /// the inverse of the .Array.data stripping does when it
+ /// normalises a property path.
+ ///
+ public static string ToSerializedPropertyPath(string graphPath) =>
+ Regex.Replace(graphPath, @"\[(\d+)\]", ".Array.data[$1]");
+
+ // Shared lookup behind both resolvers: find the sub-asset carrying fileId, then the property at propertyPath,
+ // and accept it only when it is of the expected kind.
+ private static bool TryResolveProperty(string assetPath, long fileId, string propertyPath,
+ SerializedPropertyType expected, out SerializedObject serializedObject, out SerializedProperty property)
+ {
+ serializedObject = null;
+ property = null;
+
+ foreach (var obj in AssetDatabase.LoadAllAssetsAtPath(assetPath))
+ {
+ if (obj == null) continue;
+ if (!AssetDatabase.TryGetGUIDAndLocalFileIdentifier(obj, out _, out var id) || id != fileId) continue;
+
+ var serialized = new SerializedObject(obj);
+ var found = serialized.FindProperty(propertyPath);
+ if (found is not null && found.propertyType == expected)
+ {
+ serializedObject = serialized;
+ property = found;
+ return true;
+ }
+
+ // The document matched but the path did not resolve to the expected kind — no other document shares
+ // this file id, so bail rather than scan on.
+ serialized.Dispose();
+ return false;
+ }
+
+ return false;
+ }
+
+ // A prefab component edit does not reliably flush through the generic asset-dirty path (the prefab pipeline
+ // owns its serialization), so prefabs save via SavePrefabAsset on the in-memory root; anything else via
+ // SaveAssetIfDirty.
+ private static void PersistEdit(string assetPath, Object target)
+ {
+ var prefabRoot = AssetDatabase.LoadAssetAtPath(assetPath);
+ if (prefabRoot != null) PrefabUtility.SavePrefabAsset(prefabRoot);
+ else AssetDatabase.SaveAssetIfDirty(target);
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceGraphEditor.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceGraphEditor.cs.meta
new file mode 100644
index 00000000..e6e4eb2a
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceGraphEditor.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d350585af76e425f861b7829f8959324
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceOpenCopyGuard.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceOpenCopyGuard.cs
new file mode 100644
index 00000000..f125731f
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceOpenCopyGuard.cs
@@ -0,0 +1,55 @@
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine.SceneManagement;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ ///
+ /// The writability test every YAML rewrite applies first: an asset loaded as a scene or open in Prefab Mode keeps
+ /// an in-memory copy that wins on its next save, so a file edit under it would be silently clobbered.
+ ///
+ ///
+ /// Single-asset callers use , which reports the refusal through a dialog; bulk
+ /// callers use with a hoisted stage path so a batch resolves the open
+ /// Prefab Mode stage once instead of once per entry.
+ ///
+ internal static class SerializeReferenceOpenCopyGuard
+ {
+ /// The open Prefab Mode stage's asset path, or when no stage is open.
+ public static string CurrentPrefabStagePath() => PrefabStageUtility.GetCurrentPrefabStage()?.assetPath;
+
+ /// Whether can be rewritten on disk right now.
+ public static bool IsWritable(string assetPath) => IsWritable(assetPath, CurrentPrefabStagePath());
+
+ ///
+ /// The asset to test.
+ /// A pre-resolved , hoisted out of a batch loop.
+ public static bool IsWritable(string assetPath, string prefabStagePath) =>
+ !IsOpenInScene(assetPath) && !IsOpenInPrefabMode(assetPath, prefabStagePath);
+
+ ///
+ /// Single-asset guard: returns — and explains why through a dialog — when the edit must
+ /// be abandoned because an open copy would overwrite it.
+ ///
+ public static bool BlockedByOpenCopy(string assetPath)
+ {
+ var openInPrefabMode = IsOpenInPrefabMode(assetPath, CurrentPrefabStagePath());
+ if (!IsOpenInScene(assetPath) && !openInPrefabMode) return false;
+
+ EditorUtility.DisplayDialog(
+ "Asset References",
+ "This asset is open " + (openInPrefabMode ? "in Prefab Mode" : "as a loaded scene") +
+ " — a file rewrite would be overwritten by its next save.\n\n" +
+ "Close it and rescan, or repair the field directly in the Inspector.",
+ "OK");
+ return true;
+ }
+
+ private static bool IsOpenInScene(string assetPath) => SceneManager.GetSceneByPath(assetPath).isLoaded;
+
+ private static bool IsOpenInPrefabMode(string assetPath, string prefabStagePath) =>
+ !string.IsNullOrEmpty(prefabStagePath) &&
+ string.Equals(prefabStagePath, assetPath, System.StringComparison.Ordinal);
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceOpenCopyGuard.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceOpenCopyGuard.cs.meta
new file mode 100644
index 00000000..e173f7bd
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Editing/SerializeReferenceOpenCopyGuard.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 22170f1b86904f188720eada4db2be0d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceNamePrompt.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceNamePrompt.cs
similarity index 86%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceNamePrompt.cs
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceNamePrompt.cs
index e98b778d..7283920f 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceNamePrompt.cs
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceNamePrompt.cs
@@ -11,6 +11,8 @@ namespace Aspid.FastTools.SerializeReferences.Editors
///
internal sealed class SerializeReferenceNamePrompt : EditorWindow
{
+ private const string NameFieldControl = "nameField";
+
private string _value = string.Empty;
private Action _onConfirm;
private bool _focused;
@@ -21,8 +23,9 @@ public static void Show(string title, string initial, Action onConfirm)
window.titleContent = new GUIContent(title);
window._value = initial ?? string.Empty;
window._onConfirm = onConfirm;
- window.position = new Rect(Screen.currentResolution.width / 2f - 170f, Screen.currentResolution.height / 2f - 50f, 340f, 96f);
- window.minSize = window.maxSize = new Vector2(340f, 96f);
+ var size = new Vector2(340f, 96f);
+ window.position = new Rect(Screen.currentResolution.width / 2f - size.x / 2f, Screen.currentResolution.height / 2f - size.y / 2f, size.x, size.y);
+ window.minSize = window.maxSize = size;
window.ShowModalUtility();
}
@@ -30,11 +33,11 @@ private void OnGUI()
{
GUILayout.Space(8f);
- GUI.SetNextControlName("nameField");
+ GUI.SetNextControlName(NameFieldControl);
_value = EditorGUILayout.TextField("Name", _value);
if (!_focused)
{
- EditorGUI.FocusTextInControl("nameField");
+ EditorGUI.FocusTextInControl(NameFieldControl);
_focused = true;
}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceNamePrompt.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceNamePrompt.cs.meta
similarity index 100%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceNamePrompt.cs.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/VisualElements/SerializeReferenceNamePrompt.cs.meta
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceCanvasStyle.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceCanvasStyle.cs
deleted file mode 100644
index db29e6d1..00000000
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceCanvasStyle.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using UnityEngine;
-using Aspid.FastTools.UIElements.Editors.Internal;
-
-// ReSharper disable once CheckNamespace
-namespace Aspid.FastTools.SerializeReferences.Editors
-{
- ///
- /// Status-driven tones for the windows' animated dots canvas. The whole dotted backdrop takes a single dim,
- /// desaturated hue that reflects the view's current state — blue while idle/informational, green when clean,
- /// amber when something needs attention, red on failure — so the canvas reads as a calm status wash rather than
- /// the component's default green→amber→red gradient (which implied a good→bad scale that mapped to nothing).
- ///
- internal static class SerializeReferenceCanvasStyle
- {
- ///
- /// Idle / informational backdrop (no asset, empty graph, canceled scan).
- ///
- public static readonly Color Info = new(0.15f, 0.21f, 0.30f);
-
- ///
- /// Healthy backdrop (a clean graph, a clean project).
- ///
- public static readonly Color Success = new(0.16f, 0.28f, 0.21f);
-
- ///
- /// Attention backdrop (missing references / orphans present).
- ///
- public static readonly Color Warning = new(0.30f, 0.25f, 0.12f);
-
- ///
- /// Failure backdrop, reserved for hard-error states.
- ///
- public static readonly Color Error = new(0.32f, 0.16f, 0.16f);
-
- // The component's default blob colours, mirroring the --aspid-colors-status-{success,warning,error}-text-dark
- // palette tokens the canvas USS resolves to.
- private static readonly Color SignalSuccess = new(85f / 255f, 175f / 255f, 100f / 255f);
- private static readonly Color SignalWarning = new(185f / 255f, 135f / 255f, 60f / 255f);
- private static readonly Color SignalError = new(185f / 255f, 65f / 255f, 65f / 255f);
-
- ///
- /// Paints every blob of the one . Set inline (via the
- /// component's SetColorN ) so it wins over the component's USS defaults — a preset/constructor colour is
- /// not flagged inline and would be overwritten when the stylesheet resolves.
- ///
- public static void SetTone(this AspidAnimatedDotsBackground background, Color tone) =>
- background.SetColor1(tone).SetColor2(tone).SetColor3(tone);
-
- ///
- /// Restores the green→amber→red "traffic light" gradient (the component's default three-blob look). Once a
- /// view has toned the shared canvas to a single colour via , the inline override hides
- /// the USS defaults; this re-applies them as explicit inline colours so a no-status screen (the Welcome home
- /// tab) reads as the multi-tone gradient again rather than one flat colour.
- ///
- public static void SetSignalGradient(this AspidAnimatedDotsBackground background) =>
- background.SetColor1(SignalSuccess).SetColor2(SignalWarning).SetColor3(SignalError);
- }
-}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceGraphView.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceGraphView.cs
deleted file mode 100644
index 44f1d537..00000000
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceGraphView.cs
+++ /dev/null
@@ -1,1600 +0,0 @@
-using System;
-using UnityEngine;
-using UnityEditor;
-using UnityEngine.UIElements;
-using UnityEditor.UIElements;
-using Aspid.FastTools.Editors;
-using Aspid.FastTools.UIElements;
-using System.Collections.Generic;
-using UnityEditor.SceneManagement;
-using Aspid.FastTools.Types.Editors;
-using System.Text.RegularExpressions;
-using Aspid.FastTools.UIElements.Editors.Internal;
-using System.Linq;
-using Object = UnityEngine.Object;
-using static Aspid.FastTools.SerializeReferences.Editors.SerializeReferenceAuditUI;
-
-// ReSharper disable once CheckNamespace
-namespace Aspid.FastTools.SerializeReferences.Editors
-{
- ///
- /// Asset-level visualiser for [SerializeReference] managed-reference graphs. For each serialized object
- /// document in the asset it draws the reference tree — field-pointer roots, their nested children, shared
- /// (aliased) references and orphaned payloads — straight from the YAML, so it surfaces references at any nesting
- /// depth and the orphans the Inspector cannot navigate to. Every reference card is an inline type dropdown: the
- /// same embedded picker the Repair window uses, anchored under the clicked card, where picking a type assigns /
- /// re-points the reference and <None> clears it. Healthy and empty (unassigned) slots are edited
- /// through Unity's live serialization (so the RefIds entry is created or removed exactly as the Inspector
- /// would); a missing reference — which Unity cannot reassign through the API — is re-pointed / cleared by rewriting
- /// the YAML in place, keeping its orphaned payload. Orphaned payloads no field reaches carry a Clear action.
- ///
- internal sealed class SerializeReferenceGraphView : VisualElement
- {
- private const string StyleSheetPath = "UI/SerializeReferences/Aspid-FastTools-ReferenceGraph";
-
- private const string RootClass = "aspid-fasttools-reference-graph";
- private const string ContentClass = RootClass + "__content";
- private const string CardClass = RootClass + "__card";
- private const string CardTitleClass = RootClass + "__card-title";
- private const string CardDescriptionClass = RootClass + "__card-description";
- private const string AssetClass = RootClass + "__asset";
- private const string RescanClass = RootClass + "__rescan";
- private const string EmptyClass = RootClass + "__empty";
- private const string EmptyHiddenClass = EmptyClass + "--hidden";
- private const string EmptyIconClass = RootClass + "__empty-icon";
- private const string EmptyIconInfoClass = EmptyIconClass + "--info";
- private const string EmptyTitleClass = RootClass + "__empty-title";
- private const string EmptyMessageClass = RootClass + "__empty-message";
- private const string ScrollClass = RootClass + "__scroll";
- private const string ListClass = RootClass + "__list";
- private const string ListHiddenClass = ListClass + "--hidden";
-
- private const string OverviewClass = RootClass + "__overview";
- private const string OverviewHiddenClass = OverviewClass + "--hidden";
- private const string OverviewTitleClass = RootClass + "__overview-title";
- private const string OverviewHintClass = RootClass + "__overview-hint";
-
- private const string DocumentClass = RootClass + "__document";
- private const string DocumentHeaderClass = RootClass + "__document-header";
- private const string DocumentHeaderIssuesClass = DocumentHeaderClass + "--issues";
- private const string DocumentHeaderRowClass = RootClass + "__document-header-row";
- private const string DocumentTitleClass = RootClass + "__document-title";
- private const string DocumentCountClass = RootClass + "__document-count";
- private const string DocumentBodyClass = RootClass + "__document-body";
-
- private const string NodeClass = RootClass + "__node";
- private const string NodeBackEdgeClass = NodeClass + "--back-edge";
- private const string NodeEmptyClass = NodeClass + "--empty";
- private const string NodeMigrateCardClass = NodeClass + "--migrate";
- private const string NodePickingClass = NodeClass + "--picking";
- private const string NodeHeaderHoverClass = NodeClass + "--header-hover";
- private const string NodeBandClass = RootClass + "__node-band";
- private const string NodeBandMissingClass = NodeBandClass + "--missing";
- private const string NodeBandMigrateClass = NodeBandClass + "--migrate";
- private const string NodeBandRowClass = RootClass + "__node-band-row";
- private const string NodeDividerClass = RootClass + "__node-divider";
- private const string NodeSweepClass = RootClass + "__node-sweep";
- private const string NodeSweepMissingClass = NodeSweepClass + "--missing";
- private const string NodeSweepMigrateClass = NodeSweepClass + "--migrate";
- private const string NodeActionClass = RootClass + "__node-action";
- private const string NodeActionInfoClass = NodeActionClass + "--info";
- private const string NodeHeaderClass = RootClass + "__node-header";
- private const string NodeFooterClass = RootClass + "__node-footer";
- private const string NodeRootLabelClass = RootClass + "__node-root-label";
- private const string NodeTypeClass = RootClass + "__node-type";
- private const string NodeRidClass = RootClass + "__node-rid";
- private const string NodeBadgesClass = RootClass + "__node-badges";
-
- private const string BadgeClass = RootClass + "__badge";
- private const string BadgeSharedClass = BadgeClass + "--shared";
-
- private const string LegendClass = RootClass + "__legend";
- private const string LegendHiddenClass = LegendClass + "--hidden";
- private const string LegendItemClass = RootClass + "__legend-item";
- private const string LegendDotClass = RootClass + "__legend-dot";
- private const string LegendDotInfoClass = LegendDotClass + "--info";
- private const string LegendTextClass = RootClass + "__legend-text";
- private const string NavTargetClass = RootClass + "__nav-target";
- private const string NavTargetFocusedClass = NavTargetClass + "--focused";
-
- private const string ChipClass = RootClass + "__chip";
- private const string ClearOrphanClass = RootClass + "__clear-orphan";
- private const string OrphanGroupClass = RootClass + "__orphan-group";
- private const string OrphanGroupHeaderClass = RootClass + "__orphan-group-header";
- private const string PickerClass = RootClass + "__picker";
- private const string PickerAttachedClass = PickerClass + "--attached";
-
- // Band verb + collapse chevron; TogglePicker / ClosePicker swap the chevron glyph alone, never the label.
- private const string FixCollapsedText = "Fix Missing ▼";
- private const string ChangeCollapsedText = "Change ▼";
- private const string AssignCollapsedText = "Assign ▼";
-
- // A required slot's band verb names what the amber is about: the field must be assigned, not merely can be.
- private const string AssignRequiredCollapsedText = "Assign Required ▼";
-
- // A pending-migration card is not missing (Unity migrates it in memory; only the file is stale), so no "Missing".
- private const string MigrateFixCollapsedText = "Fix ▼";
- private const char BandChevronCollapsed = '▼';
- private const char BandChevronExpanded = '▲';
-
- // Single-sourced from the picker's "" option so an empty slot reads like a cleared field in the Inspector.
- private const string EmptySlotText = TypeSelectorHelpers.NoneOption;
-
- private const string DocumentChevronExpanded = "▼";
- private const string DocumentChevronCollapsed = "▶";
-
- // Reports this view's state-tone to the host window, which owns the shared dotted canvas behind every mode.
- private readonly Action _onCanvasTone;
-
- // Reports a target change to the host window: it rebuilds this view from its cached target on every tab switch,
- // so without this an in-view pick would be dropped on the next return to this tab.
- private readonly Action _onTargetChanged;
-
- private Object _target;
- private readonly ObjectField _assetField;
- private readonly AspidGradientButton _rescanButton;
- private readonly VisualElement _empty;
- private readonly VisualElement _overview;
- private readonly AspidLabel _overviewTitle;
- private readonly Label _overviewHint;
- private readonly VisualElement _legend;
- private readonly VisualElement _list;
- private readonly ScrollView _scroll;
-
- private VisualElement _openPicker;
- private AspidGradientButton _openPickerRow;
- private VisualElement _openPickerCard;
-
- // Keyboard navigation: one flat focus ring over every actionable element in visual order — Rescan first, then
- // each document header, node band, action row and orphan Clear — shared with the other window tabs, so a
- // member hidden inside a collapsed document band drops out of the ring (see NavRing).
- private readonly NavRing _ring;
-
- // The legend's block-specific USS class names for the shared item builder.
- private static readonly LegendClasses LegendClassSet = new(LegendItemClass, LegendDotClass, LegendDotInfoClass, LegendTextClass);
-
- // Per-asset constraint map cache: BuildConstraintMap does a LoadAllAssetsAtPath + full SerializedObject walk,
- // so each Fix-Missing picker open must not re-scan. Cleared on every Rescan / apply so rewritten YAML is re-read.
- private readonly Dictionary> _constraintCache = new(StringComparer.Ordinal);
-
- // Unset [TypeSelector(Required = true)] fields for the current asset, refreshed on every Rescan. Populated
- // straight from SerializeReferenceGateScanner — the same required-field check the Project References audit
- // and the build/CI gate use — so the amber required styling here always agrees with them. A required
- // string/SerializableType field has no rid and so no graph node; it gets its own trailing card instead
- // (BuildRequiredOnlyCard).
- private IReadOnlyList _requiredViolations = Array.Empty();
-
- public SerializeReferenceGraphView(Object target, Action onCanvasTone, Action onTargetChanged = null)
- {
- _target = target;
- _onCanvasTone = onCanvasTone;
- _onTargetChanged = onTargetChanged;
-
- var root = this;
- style.flexGrow = 1;
- root.AddAspidThemeStyleSheets()
- .AddStyleSheetsFromResource(StyleSheetPath)
- .AddClass(RootClass);
-
- var cardTitle = new AspidLabel("Inspect asset", AspidLabelPreset.Default
- .SetLabelTheme(ThemeStyle.Type.Lightness)
- .SetLabelSize(AspidLabelSizeStyle.Type.H5)
- .SetLineSize(AspidDividingLineSizeStyle.Type.None))
- .AddClass(CardTitleClass);
-
- var cardDescription = new Label(
- "Map a saved asset's [SerializeReference] graph and repair missing types inline.")
- .AddClass(CardDescriptionClass);
-
- _assetField = new ObjectField
- {
- objectType = typeof(Object),
- allowSceneObjects = false,
- value = _target,
- };
- _assetField.AddClass(AssetClass);
- _assetField.RegisterValueChangedCallback(evt => SetTarget(evt.newValue));
-
- // The field is hosted inside the Rescan button: swallow its presses so opening the object picker or
- // dragging an asset in doesn't bubble to the button's Clickable and re-run Rescan.
- _assetField.RegisterCallback(evt => evt.StopPropagation());
-
- _rescanButton = new AspidGradientButton("Rescan", _ => Rescan())
- .AddClass(RescanClass);
- _rescanButton.AddTrailingContent(_assetField);
- _rescanButton.FillWithTrailingContent();
-
- var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
- .AddClass(CardClass)
- .AddChild(cardTitle)
- .AddChild(cardDescription)
- .AddChild(_rescanButton);
-
- _empty = new VisualElement().AddClass(EmptyClass);
-
- _overviewTitle = new AspidLabel(string.Empty, AspidLabelPreset.Default
- .SetLabelStatus(StatusStyle.Type.Warning)
- .SetLabelSize(AspidLabelSizeStyle.Type.H4)
- .SetLineTheme(ThemeStyle.Type.Dark)
- .SetLineStatus(StatusStyle.Type.Warning))
- .AddClass(OverviewTitleClass);
-
- _overviewHint = new Label(string.Empty).AddClass(OverviewHintClass);
-
- // Color key for the two card accents; only shown when both are actually on screen (see ShowOverview) —
- // the same amber/blue legend the Project References audit renders under its hint.
- _legend = new VisualElement()
- .AddClass(LegendClass)
- .AddClass(LegendHiddenClass)
- .AddChild(BuildLegendItem("Broken — pick a replacement", info: false, LegendClassSet))
- .AddChild(BuildLegendItem("Renamed — one-click migrate", info: true, LegendClassSet));
-
- _overview = new VisualElement()
- .AddClass(OverviewClass)
- .AddClass(OverviewHiddenClass)
- .AddChild(_overviewTitle)
- .AddChild(_overviewHint)
- .AddChild(_legend);
-
- _list = new VisualElement().AddClass(ListClass);
-
- // One scroll spans the whole view, so the card and overview scroll away with the document list rather than
- // staying pinned above a separately-scrolling list.
- var content = new VisualElement()
- .AddClass(ContentClass)
- .AddChild(card)
- .AddChild(_empty)
- .AddChild(_overview)
- .AddChild(_list);
-
- _scroll = new ScrollView().AddClass(ScrollClass);
- _scroll.AddChild(content);
-
- root.AddChild(_scroll);
-
- // The shared keyboard ring: the root holds focus (grabbed on attach, re-grabbed when the picker closes)
- // so keys reach it before anything is highlighted. Suspended while a type picker owns the keyboard.
- _ring = new NavRing(
- host: this,
- navTargetClass: NavTargetClass,
- paint: PaintNavFocus,
- scrollTo: element => _scroll.ScrollTo(element),
- isSuspended: () => _openPicker is not null);
-
- Rescan();
- }
-
- // ---------------------------------------------------------------------------------------------------------
- // Keyboard navigation (mirrors SerializeReferenceProjectView)
- // ---------------------------------------------------------------------------------------------------------
-
- // Gradient buttons paint their hover in code (accent overlay + tinted labels); the focused class's flat fill
- // would just show through their fading gradient as a gray pill, so they take ONLY the programmatic hover and
- // the plain rows take ONLY the class. A focused node band also lights its card's divider sweep.
- private static void PaintNavFocus(VisualElement element, bool on)
- {
- if (element is AspidGradientButton button) button.Highlighted = on;
- else element.EnableInClassList(NavTargetFocusedClass, on);
- SetBandSweep(element, on);
- }
-
- // A focused node band also lights its card's divider sweep — the same card-level hover mirror the mouse
- // path drives in AddBandDivider — so keyboard focus and mouse hover render identically.
- private static void SetBandSweep(VisualElement element, bool on)
- {
- if (element.ClassListContains(NodeBandClass))
- element.parent?.EnableInClassList(NodeHeaderHoverClass, on);
- }
-
- // Every render pass rebuilds the ring from scratch (the old elements are gone with _list.Clear()). Rescan is
- // always slot 0; a highlight sitting on it survives the rebuild, so Enter-on-Rescan keeps its highlight.
- private void ResetNavTargets()
- {
- var keepScanFocus = _ring.Index == 0;
- _ring.Clear();
-
- RegisterNavTarget(_rescanButton, () => Rescan());
- if (keepScanFocus) _ring.Focus(0, scrollTo: false);
- }
-
- private void RegisterNavTarget(VisualElement element, Action activate) => _ring.Register(element, activate);
-
- private void SetTarget(Object target)
- {
- _target = target;
- // Mirror the pick back to the host so its cached target follows; the host just stores it (no rebuild),
- // so this never re-enters.
- _onTargetChanged?.Invoke(target);
- // Open() retargets an already-open window, so the field must follow the new target — without notifying,
- // or the change callback would trigger a second scan.
- _assetField?.SetValueWithoutNotify(target);
- if (_list is not null) Rescan();
- }
-
- private void Rescan(List prebuilt = null)
- {
- if (_list is null) return;
-
- ClosePicker();
- // Drop the constraint maps so a rescan after a fix / clear re-reads the rewritten YAML, not a stale map.
- _constraintCache.Clear();
- _list.Clear();
- ResetNavTargets();
- _requiredViolations = Array.Empty();
-
- var assetPath = _target ? AssetDatabase.GetAssetPath(_target) : null;
- if (string.IsNullOrEmpty(assetPath))
- {
- // A nested prefab instance keeps its managed-reference data in the source prefab, not the host, so offer
- // to retarget the graph onto that source where the RefIds actually live.
- if (SerializeReferenceHelpers.TryGetSourcePrefabPath(_target, out var sourcePath))
- {
- ShowResults();
- _onCanvasTone?.Invoke(SerializeReferenceCanvasStyle.Info);
-
- var info = new AspidHelpBox(AspidHelpBoxPreset.Default.SetMessageType(HelpBoxMessageType.Info));
- info.Message = "This is a prefab instance — its managed references live in the source prefab.";
- _list.AddChild(info);
-
- var openSource = new AspidGradientButton("Open Source Prefab",
- _ => SetTarget(AssetDatabase.LoadAssetAtPath(sourcePath)));
- RegisterNavTarget(openSource, () => SetTarget(AssetDatabase.LoadAssetAtPath(sourcePath)));
- _list.AddChild(openSource);
- return;
- }
-
- ShowEmpty(
- "No asset selected",
- "Select a saved asset (a prefab or ScriptableObject) to map its managed-reference graph.");
- return;
- }
-
- var documents = prebuilt ?? SerializeReferenceGraphScanner.Build(assetPath);
-
- // Same headless scanner as the Project References audit and the build/CI gate, scoped to this one asset.
- // Read before the empty-graph bail: a string / SerializableType required field has no rid and so never
- // produces a document (SerializeReferenceGraphScanner only emits one for an object with a RefIds block),
- // so it can be the ONLY thing this asset has to show even when the managed-reference graph is empty.
- _requiredViolations = SerializeReferenceGateScanner.ScanAssetRequiredFields(assetPath);
-
- if (documents.Count == 0 && _requiredViolations.Count == 0)
- {
- ShowEmpty(
- "No managed references",
- "This asset has no [SerializeReference] managed references to map.");
- return;
- }
-
- ShowResults();
-
- // Empty (unassigned) slots are tallied separately: they are not broken, so they never tip the
- // headline / canvas to amber — they only surface in the dim hint.
- var total = 0;
- var missing = 0;
- var orphans = 0;
- var empties = 0;
- var migrations = 0;
-
- // Every empty managed-reference slot's normalized path, gathered up front so the required-only cards
- // below can tell "already badged on a graph card" apart from "no graph node exists for this field at
- // all" (a string / SerializableType required field, or an empty slot under a document the scanner
- // failed to reach) without re-walking the tree per violation.
- var emptySlotPaths = CollectEmptySlotPaths(documents);
-
- var showHeaders = documents.Count > 1;
- foreach (var document in documents)
- {
- _list.AddChild(BuildDocument(assetPath, document, showHeaders));
-
- total += document.Nodes.Count;
- var (broken, documentMigrations) = CountUnresolved(assetPath, document);
- missing += broken + documentMigrations;
- migrations += documentMigrations;
- orphans += document.Orphans.Count;
- empties += CountEmptySlots(document);
- }
-
- // Fields the graph has no node for at all: string / SerializableType required fields (never threaded
- // into RefIds) plus, defensively, any managed-reference violation the graph walk could not place.
- var ungraphedRequired = _requiredViolations
- .Where(v => !emptySlotPaths.Contains((v.FileId, v.FieldPath)))
- .ToList();
-
- // The headline's "unassigned fields" note counts only slots that are allowed to stay empty — a required
- // empty slot is reported through the required count instead, never twice.
- var required = _requiredViolations.Count;
- var graphedRequired = required - ungraphedRequired.Count;
- ShowOverview(total, missing, orphans, Math.Max(0, empties - graphedRequired), migrations, required);
-
- if (ungraphedRequired.Count > 0)
- {
- // Per-card memo, keyed by asset path: several violations commonly share one component, so this keeps
- // LoadAllAssetsAtPath to once per distinct file instead of once per card.
- var componentCache = new Dictionary(StringComparer.Ordinal);
- foreach (var violation in ungraphedRequired)
- _list.AddChild(BuildRequiredOnlyCard(violation, componentCache));
- }
-
- // Pending migrations are not breakages — a graph whose only annotations are migrations reads info-blue,
- // matching the Project References group card; anything missing / orphaned / required-unset keeps the
- // amber wash (same issue set that tips the ShowOverview headline).
- _onCanvasTone?.Invoke(missing - migrations > 0 || orphans > 0 || required > 0
- ? SerializeReferenceCanvasStyle.Warning
- : migrations > 0
- ? SerializeReferenceCanvasStyle.Info
- : SerializeReferenceCanvasStyle.Success);
- }
-
- // Every empty managed-reference slot's normalized field path across every document, root and nested edge
- // (mirrors the AppendNode walk, minus the card building) — the lookup set BuildEmptySlotCard's badges are
- // checked against, reused here to find the required violations no graph card exists for.
- private static HashSet<(long fileId, string path)> CollectEmptySlotPaths(List documents)
- {
- var paths = new HashSet<(long, string)>();
-
- foreach (var document in documents)
- {
- foreach (var root in document.Roots)
- {
- if (root.IsEmpty)
- paths.Add((document.FileId, ToSerializedPropertyPath(root.Label)));
- else
- WalkForEmptySlots(document, root.Rid, root.Label, new HashSet(), paths);
- }
- }
-
- return paths;
- }
-
- private static void WalkForEmptySlots(ReferenceGraphDocument document, long rid, string pathLabel,
- HashSet visited, HashSet<(long fileId, string path)> paths)
- {
- if (!visited.Add(rid)) return;
-
- foreach (var edge in document.ChildrenOf(rid))
- {
- var childPath = CombinePath(pathLabel, edge.Label);
- if (edge.IsEmpty)
- paths.Add((document.FileId, ToSerializedPropertyPath(childPath)));
- else
- WalkForEmptySlots(document, edge.Rid, childPath, visited, paths);
- }
-
- visited.Remove(rid);
- }
-
- // Ranked Smart Fix for a missing node, via the shared per-(path, fileId, rid) cache so a rescan and the
- // inline drawer reuse one computation. Best-effort: a parse miss just means no suggestion row.
- private bool TryGetNodeSuggestion(string assetPath, long fileId, long rid, ManagedTypeName storedType,
- out SerializeReferenceRepairSuggestions.RepairCandidate suggestion)
- {
- suggestion = default;
-
- try
- {
- var fieldNames = SerializeReferenceYamlEditor.GetReferenceFieldNames(assetPath, fileId, rid);
- var constraint = ResolveConstraint(assetPath, fileId, rid) ?? typeof(object);
-
- var ranked = SerializeReferenceRepairSuggestions.GetCached(assetPath, fileId, rid,
- () => SerializeReferenceRepairSuggestions.Rank(storedType, fieldNames, constraint));
- if (ranked.Count == 0) return false;
-
- suggestion = ranked[0];
- return true;
- }
- catch (Exception)
- {
- return false;
- }
- }
-
- // A missing node whose stored type is claimed by exactly one [MovedFrom] target that fits the field's declared
- // type reads as a pending migration. An unrecoverable constraint lets the migration through.
- private bool IsPendingMigration(string assetPath, long fileId, long rid, ManagedTypeName storedType, out Type target)
- {
- if (!SerializeReferenceMovedFromResolver.TryResolve(storedType, out target)) return false;
-
- var constraint = ResolveConstraint(assetPath, fileId, rid);
- return constraint is null || constraint == typeof(object) || constraint.IsAssignableFrom(target);
- }
-
- // Used only for the overview hint; empty slots are not "issues".
- private static int CountEmptySlots(ReferenceGraphDocument document)
- {
- var count = document.Roots.Count(root => root.IsEmpty);
-
- foreach (var pair in document.Edges)
- {
- count += pair.Value.Count(edge => edge.IsEmpty);
- }
-
- return count;
- }
-
- // Splits a document's unresolved nodes into genuinely broken ones and pending [MovedFrom] migrations. An
- // orphaned rid always counts as broken — nothing loads an orphan, so in-memory migration does not apply.
- private (int broken, int migrations) CountUnresolved(string assetPath, ReferenceGraphDocument document)
- {
- var broken = 0;
- var migrations = 0;
-
- foreach (var node in document.Nodes)
- {
- if (node.Resolves || node.StoredType.IsEmpty) continue;
-
- // An unresolved orphan is one entity, already counted (and amber-glowed) by the orphan tallies —
- // adding it to "missing" too would double-count it in the overview headline and hints.
- if (document.Orphans.Contains(node.Rid)) continue;
-
- if (IsPendingMigration(assetPath, document.FileId, node.Rid, node.StoredType, out _))
- migrations++;
- else
- broken++;
- }
-
- return (broken, migrations);
- }
-
- // The same missing-predicate the amber tint uses; drives the missing-first root ordering in BuildDocument.
- private static bool RootIsMissing(ReferenceGraphDocument document, long rid)
- {
- var node = document.FindNode(rid);
- return node is { Resolves: false } && !node.Value.StoredType.IsEmpty;
- }
-
- private void ShowEmpty(string title, string message)
- {
- HideOverview();
- _list.AddClass(ListHiddenClass);
- _empty.RemoveClass(EmptyHiddenClass);
- _empty.Clear();
- _onCanvasTone?.Invoke(SerializeReferenceCanvasStyle.Info);
-
- var icon = new VisualElement()
- .AddClass(EmptyIconClass)
- .AddClass(EmptyIconInfoClass);
-
- _empty.AddChild(icon)
- .AddChild(new AspidLabel(title, AspidLabelPreset.Default
- .SetLabelTheme(ThemeStyle.Type.Lightness)
- .SetLabelSize(AspidLabelSizeStyle.Type.H3)
- .SetLineSize(AspidDividingLineSizeStyle.Type.None))
- .AddClass(EmptyTitleClass))
- .AddChild(new Label(message).AddClass(EmptyMessageClass));
- }
-
- private void ShowResults()
- {
- // The overview stays hidden here; only the document-graph path (Rescan) re-shows it, so the
- // prefab-instance branch that reuses ShowResults keeps the missing-reference headline suppressed.
- HideOverview();
- _empty.AddClass(EmptyHiddenClass);
- _list.RemoveClass(ListHiddenClass);
- }
-
- private void ShowOverview(int total, int missing, int orphans, int empties, int migrations, int required)
- {
- // Genuinely missing / orphaned references and unset required fields are "issues" that tip the headline
- // and divider to amber; pending migrations are stale files, not breakages (info), and non-required empty
- // slots are unassigned, not broken.
- var broken = missing - migrations;
- var status = broken > 0 || orphans > 0 || required > 0
- ? StatusStyle.Type.Warning
- : migrations > 0
- ? StatusStyle.Type.Info
- : StatusStyle.Type.Success;
-
- _overviewTitle.Text = BuildOverviewTitle(broken, orphans, migrations, required);
-
- _overviewTitle.LabelStatus = status;
- _overviewTitle.LineStatus = status;
-
- _overviewHint.text = BuildOverviewHint(total, missing, orphans, empties, migrations, required);
-
- // The amber/blue key only earns its row when both accents are on screen at once (see the Project
- // References legend).
- var hasAmber = broken > 0 || orphans > 0 || required > 0;
- _legend.EnableInClassList(LegendHiddenClass, migrations == 0 || !hasAmber);
-
- _overview.RemoveClass(OverviewHiddenClass);
- }
-
- // Only non-zero parts make the headline, joined like the Project References results header — so an asset
- // carrying several finding kinds names all of them instead of hiding the rest in the hint.
- private static string BuildOverviewTitle(int broken, int orphans, int migrations, int required)
- {
- var parts = new List(4);
- if (broken > 0) parts.Add(BuildCountText(broken, "missing reference"));
- if (orphans > 0) parts.Add(BuildCountText(orphans, "orphaned reference"));
- if (required > 0) parts.Add(BuildCountText(required, "required violation"));
- if (migrations > 0) parts.Add(BuildCountText(migrations, "pending migration"));
-
- return parts.Count > 0 ? string.Join(", ", parts) : "No missing references";
- }
-
- private static string BuildOverviewHint(int total, int missing, int orphans, int empties, int migrations, int required)
- {
- var references = total == 1 ? "1 managed reference" : $"{total} managed references";
- var emptyNote = empties switch
- {
- 0 => string.Empty,
- 1 => " · 1 unassigned field",
- _ => $" · {empties} unassigned fields"
- };
-
- if (missing == 0 && orphans == 0 && required == 0)
- return $"{references} mapped{emptyNote} — every [SerializeReference] type resolves.";
-
- var broken = missing - migrations;
-
- var parts = new List(5);
- if (broken > 0) parts.Add(broken == 1 ? "1 missing type" : $"{broken} missing types");
- if (migrations > 0) parts.Add(migrations == 1 ? "1 pending [MovedFrom] migration" : $"{migrations} pending [MovedFrom] migrations");
- if (orphans > 0) parts.Add(orphans == 1 ? "1 orphaned rid" : $"{orphans} orphaned rids");
- if (required > 0) parts.Add(required == 1 ? "1 required field unassigned" : $"{required} required fields unassigned");
- if (empties > 0) parts.Add(empties == 1 ? "1 unassigned field" : $"{empties} unassigned fields");
-
- var action = broken > 0
- ? "Fix a missing type inline from its card."
- : required > 0
- ? "Assign each required field from its amber card."
- : migrations > 0
- ? "Migrate a renamed type from its card — the Inspector already loads it; only the file is stale."
- : "Clear an orphaned rid from its card.";
-
- return $"{references} mapped · {string.Join(" · ", parts)}. {action}";
- }
-
- private void HideOverview() => _overview?.AddClass(OverviewHiddenClass);
-
- // One serialized object document: a collapsible header band over a flat stack of node cards (nesting is read
- // from each card's field path, not indentation) plus a trailing "Orphaned" group. The header is dropped for a
- // single-document asset — there it would only restate the ObjectField above it.
- private VisualElement BuildDocument(string assetPath, ReferenceGraphDocument document, bool showHeader)
- {
- // Pending migrations are not issues — a document whose only findings are migrations keeps the calm
- // header, matching the info-toned overview; orphans and genuinely broken nodes still glow amber.
- var (broken, migrations) = CountUnresolved(assetPath, document);
- var hasIssues = document.Orphans.Count > 0 || broken > 0;
-
- var body = new VisualElement().AddClass(DocumentBodyClass);
-
- // The header is built (and registered on the nav ring) BEFORE the body cards, so the keyboard order
- // matches the visual order — the band sits above the cards it collapses.
- AspidGradientButton header = null;
- if (showHeader)
- {
- // The self-reference lets the click handler flip its own chevron alongside toggling the body.
- var collapsed = false;
- var toggle = new Action(() =>
- {
- collapsed = !collapsed;
- body.style.display = collapsed ? DisplayStyle.None : DisplayStyle.Flex;
- header.Text = collapsed ? DocumentChevronCollapsed : DocumentChevronExpanded;
- });
- header = new AspidGradientButton(DocumentChevronExpanded, _ => toggle())
- .AddClass(DocumentHeaderClass);
- if (hasIssues) header.AddClass(DocumentHeaderIssuesClass);
- header.tooltip = $"fileId {document.FileId}";
- RegisterNavTarget(header, toggle);
-
- // Ignored for picking so clicks fall through to the band's own handler.
- header.AddLeadingContent(new VisualElement()
- .AddClass(DocumentHeaderRowClass)
- .SetPickingMode(PickingMode.Ignore)
- .AddChild(new Label(document.TypeName)
- .AddClass(DocumentTitleClass)
- .SetPickingMode(PickingMode.Ignore))
- .AddChild(new Label(BuildDocumentCountText(document, broken, migrations))
- .AddClass(DocumentCountClass)
- .SetPickingMode(PickingMode.Ignore)));
- }
-
- // Missing roots render first. Two passes over the asset's field order keep the partition stable between
- // rescans; empty (unassigned) roots are not missing, so they fall to the second pass.
- foreach (var root in document.Roots)
- {
- if (root.IsEmpty || !RootIsMissing(document, root.Rid)) continue;
- var visited = new HashSet();
- AppendNode(body, assetPath, document, root.Rid, root.Label, visited);
- }
-
- foreach (var root in document.Roots)
- {
- if (root.IsEmpty)
- {
- body.AddChild(BuildEmptySlotCard(assetPath, document.FileId, root.Label));
- continue;
- }
-
- if (RootIsMissing(document, root.Rid)) continue;
- var visited = new HashSet();
- AppendNode(body, assetPath, document, root.Rid, root.Label, visited);
- }
-
- var orphans = BuildOrphanGroup(assetPath, document);
- if (orphans is not null) body.AddChild(orphans);
-
- // Single-document asset: no header band — the ObjectField above already names it. Always expanded.
- if (header is null)
- return new VisualElement().AddClass(DocumentClass).AddChild(body);
-
- return new VisualElement()
- .AddClass(DocumentClass)
- .AddChild(header)
- .AddChild(body);
- }
-
- // A pending [MovedFrom] migration is named as such so the header never contradicts the overview's "0 missing".
- private static string BuildDocumentCountText(ReferenceGraphDocument document, int broken, int migrations)
- {
- var total = document.Nodes.Count;
- var orphans = document.Orphans.Count;
-
- var text = total == 1 ? "1 reference" : $"{total} references";
- if (broken > 0) text += $" · {broken} missing";
- if (migrations > 0) text += migrations == 1 ? " · 1 migration" : $" · {migrations} migrations";
- if (orphans > 0) text += orphans == 1 ? " · 1 orphaned" : $" · {orphans} orphaned";
- return text;
- }
-
- // Appends a node's card and, recursively, its children's as flat siblings — nesting is carried by the threaded
- // field path, not the layout. The visited set makes the walk cycle-safe: a rid already on the current path
- // renders as a back-edge leaf instead of recursing forever.
- private void AppendNode(VisualElement container, string assetPath, ReferenceGraphDocument document, long rid, string pathLabel, HashSet visited)
- {
- if (!visited.Add(rid))
- {
- container.AddChild(BuildBackEdgeCard(rid));
- return;
- }
-
- var node = document.FindNode(rid);
- container.AddChild(BuildNodeCard(assetPath, document, node, rid, pathLabel, isOrphan: false));
-
- foreach (var edge in document.ChildrenOf(rid))
- {
- var childPath = CombinePath(pathLabel, edge.Label);
- if (edge.IsEmpty)
- container.AddChild(BuildEmptySlotCard(assetPath, document.FileId, childPath));
- else
- AppendNode(container, assetPath, document, edge.Rid, childPath, visited);
- }
-
- // Leaving the recursion: drop the rid so a sibling subtree may legitimately reference it again (shared),
- // while a back-edge on the current path is still caught above.
- visited.Remove(rid);
- }
-
- private static string CombinePath(string parent, string child)
- {
- if (string.IsNullOrEmpty(child)) return parent;
- return string.IsNullOrEmpty(parent) ? child : $"{parent}.{child}";
- }
-
- // A node card whose band is an inline dropdown: a missing card edits through the YAML, a healthy one through
- // the live serialization API, an orphan keeps a static band plus a footer Clear. Cards are not indented —
- // the field path alone carries the nesting.
- private VisualElement BuildNodeCard(string assetPath, ReferenceGraphDocument document, ReferenceGraphNode? node, long rid, string pathLabel, bool isOrphan)
- {
- var missing = node is { Resolves: false } && !node.Value.StoredType.IsEmpty;
-
- // An authoritative [MovedFrom] rename is a pending migration, not a breakage: Unity loads the reference
- // fine — only this file still stores the old name. Never for an orphan — nothing loads an orphan, so the
- // in-memory migration argument does not hold.
- Type migrationTarget = null;
- var isMigration = missing && !isOrphan &&
- IsPendingMigration(assetPath, document.FileId, rid, node.Value.StoredType, out migrationTarget);
-
- var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
- .AddClass(NodeClass);
- // Card-level modifier so card-wide states (the --picking accent frame, the picker's accent-follow rules)
- // read the calm info tone on a migration card instead of the broken-card amber.
- if (isMigration) card.AddClass(NodeMigrateCardClass);
-
- var typePreset = AspidLabelPreset.Default
- .SetLabelSize(AspidLabelSizeStyle.Type.H5)
- .SetLineSize(AspidDividingLineSizeStyle.Type.None);
- typePreset = isMigration
- ? typePreset.SetLabelStatus(StatusStyle.Type.Info)
- : missing || isOrphan
- ? typePreset.SetLabelStatus(StatusStyle.Type.Warning)
- : typePreset.SetLabelTheme(ThemeStyle.Type.Lightness);
-
- var typeLabel = new AspidLabel(node?.ShortName ?? $"rid {rid}", typePreset)
- .AddClass(NodeTypeClass)
- .SetPickingMode(PickingMode.Ignore);
- if (node is not null && !node.Value.StoredType.IsEmpty)
- typeLabel.tooltip = node.Value.FullName;
-
- // No MISSING badge — the band action and amber type pill already carry it; only SHARED remains.
- var badges = new VisualElement()
- .AddClass(NodeBadgesClass)
- .SetPickingMode(PickingMode.Ignore);
-
- if (document.Shared.Contains(rid))
- {
- var shared = new Label("SHARED").AddClass(BadgeClass).AddClass(BadgeSharedClass);
- var chip = new VisualElement().AddClass(ChipClass);
- chip.style.backgroundColor = SerializeReferenceRidColor.ForRid(rid);
- shared.AddChild(chip);
-
- badges.AddChild(shared);
- }
-
- var bandRow = new VisualElement()
- .AddClass(NodeBandRowClass)
- .AddChild(typeLabel)
- .AddChild(badges);
- bandRow.pickingMode = PickingMode.Ignore;
-
- if (missing)
- {
- // The captured file id targets the rewrite at exactly this document's rid (rids collide across docs).
- // A missing reference cannot be reassigned through the serialization API, so its edit goes through the
- // YAML (keeping the orphaned payload).
- var fileId = document.FileId;
- AspidGradientButton band = null;
- band = new AspidGradientButton(isMigration ? MigrateFixCollapsedText : FixCollapsedText,
- _ => OpenMissingPicker(assetPath, fileId, rid, band))
- .AddClass(NodeBandClass)
- .AddClass(isMigration ? NodeBandMigrateClass : NodeBandMissingClass);
- band.AddLeadingContent(bandRow);
- card.AddChild(band);
- RegisterNavTarget(band, () => OpenMissingPicker(assetPath, fileId, rid, band));
- AddBandDivider(card, band, isMigration ? NodeSweepMigrateClass : NodeSweepMissingClass);
-
- if (isMigration)
- {
- // The same YAML rewrite a picker pick performs — no confirm, matching the picker's own apply.
- card.AddChild(BuildNodeActionRow(
- $"Migrate → {migrationTarget.Name}",
- $"This entry resolves to {migrationTarget.FullName} via its declared [MovedFrom] — Unity " +
- "already migrates it in memory when the asset loads. Migrating rewrites the stored type " +
- "name in the file so it matches the code.",
- info: true,
- () => ApplyFix(assetPath, fileId, rid, migrationTarget.AssemblyQualifiedName)));
- }
- else if (TryGetNodeSuggestion(assetPath, document.FileId, rid, node.Value.StoredType, out var suggestion))
- {
- // Safe to hand straight to ApplyFix: Rank's pool is constraint-filtered, so the suggestion is
- // always a type the picker itself would offer.
- card.AddChild(BuildNodeActionRow(
- $"Smart Fix {SerializeReferenceHelpers.GetSuggestionLabel(suggestion)}",
- SerializeReferenceHelpers.GetSuggestionDetail(suggestion),
- info: false,
- () => ApplyFix(assetPath, fileId, rid, suggestion.Type.AssemblyQualifiedName)));
- }
- }
- else if (!isOrphan)
- {
- // A healthy reference edits through the live serialization API (keyed by the field path), so Unity
- // rewrites — or, on , removes — the RefIds entry exactly as the Inspector would.
- var fileId = document.FileId;
- var graphPath = pathLabel;
- AspidGradientButton band = null;
- band = new AspidGradientButton(ChangeCollapsedText, _ => OpenLivePicker(assetPath, fileId, graphPath, band))
- .AddClass(NodeBandClass);
- band.AddLeadingContent(bandRow);
- card.AddChild(band);
- RegisterNavTarget(band, () => OpenLivePicker(assetPath, fileId, graphPath, band));
- AddBandDivider(card, band, sweepModifier: null);
- }
- else
- {
- // An orphan has no field pointing at it, so there is no live property to edit — its band stays static
- // and the footer Clear (below) drops the dangling entry. The divider still splits band from footer,
- // but with no hover source there is no sweep.
- card.AddChild(bandRow);
- AddBandDivider(card, band: null, sweepModifier: null);
- }
-
- // Healthy and empty slots are cleared through their band's picker (), so no separate button here.
- var meta = new VisualElement().AddClass(NodeFooterClass);
-
- if (!string.IsNullOrEmpty(pathLabel))
- {
- meta.AddChild(MakeSelectable(new Label($"{pathLabel}:")
- .AddClass(NodeRootLabelClass)));
- }
-
- meta.AddChild(MakeSelectable(new Label($"rid {rid}")
- .AddClass(NodeRidClass)));
-
- if (isOrphan)
- {
- // Drop a dangling RefIds entry no field points at. File edit, so it is confirmed and not undoable.
- var fileId = document.FileId;
- var clear = new AspidGradientButton("Clear", _ => ClearOrphan(assetPath, fileId, rid))
- .AddClass(ClearOrphanClass);
- RegisterNavTarget(clear, () => ClearOrphan(assetPath, fileId, rid));
- meta.AddChild(clear);
- }
-
- card.AddChild(meta);
-
- return card;
- }
-
- // A one-click action (Smart Fix / Migrate) as a flat accent verb over the same hover fill the Project
- // References action rows use, instead of a filled gradient pill floating over the glass card. Each card
- // keeps one accent: warning amber for a Smart Fix guess on a broken card, info for a pending migration.
- private VisualElement BuildNodeActionRow(string text, string tooltipText, bool info, Action onClick)
- {
- var row = new Label(text).AddClass(NodeActionClass);
- if (info) row.AddClass(NodeActionInfoClass);
- row.tooltip = tooltipText;
- row.RegisterCallback(_ => onClick());
- RegisterNavTarget(row, onClick);
- return row;
- }
-
- // The dim hairline between a card's band and its body, plus — when the band is interactive — the accent
- // underline sweep that scales in while the band is hovered (the Project References group cards' idiom).
- // The sweep is the band's sibling, so USS :hover can't reach it; the band mirrors its hover onto a card
- // modifier the sweep rule listens to. Both hide while the picker is docked (see the --picking USS rules).
- private void AddBandDivider(VisualElement card, AspidGradientButton band, string sweepModifier)
- {
- card.AddChild(new AspidDividingLine(AspidDividingLinePreset.Default
- .SetTheme(ThemeStyle.Type.Light)
- .SetSize(AspidDividingLineSizeStyle.Type.Thin))
- .AddClass(NodeDividerClass));
-
- if (band is null) return;
-
- var sweep = new VisualElement()
- .AddClass(NodeSweepClass)
- .SetPickingMode(PickingMode.Ignore);
- if (sweepModifier is not null) sweep.AddClass(sweepModifier);
- card.AddChild(sweep);
-
- // HoverSweep mirrors the band's hover onto the card modifier the sweep rule listens to, keeping it lit
- // while the band is the nav-focused ring member.
- HoverSweep.MirrorHover(band, () => band.parent, NodeHeaderHoverClass, () => _ring.IsFocused(band));
- }
-
- // A back-edge to a rid already on the current render path — a single dim, italic line (no footer) so cycles
- // terminate visibly.
- private static VisualElement BuildBackEdgeCard(long rid)
- {
- var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
- .AddClass(NodeClass)
- .AddClass(NodeBackEdgeClass);
-
- card.AddChild(new VisualElement()
- .AddClass(NodeHeaderClass)
- .AddChild(new Label($"↩ rid {rid}")
- .AddClass(NodeTypeClass)
- .SetPickingMode(PickingMode.Ignore)));
-
- return card;
- }
-
- // Matches an empty slot's graph field path (list indices as "[i]") against a GateViolation's FieldPath (Unity's
- // native SerializedProperty form, "Array.data[i]") for the same document — the same normalization
- // TryResolveLiveProperty already applies to reach the live property at this path. Best-effort: a slot whose
- // path could not be recovered by the YAML walk (SerializeReferenceGraphScanner's "reference" fallback) never
- // matches a real property path, so its badge is silently skipped rather than false-positiving — the same
- // violation still shows correctly in the Project References tab.
- private bool IsFieldRequiredUnset(long fileId, string pathLabel)
- {
- if (string.IsNullOrEmpty(pathLabel) || _requiredViolations.Count == 0) return false;
-
- var propertyPath = ToSerializedPropertyPath(pathLabel);
- foreach (var violation in _requiredViolations)
- {
- if (violation.FileId == fileId && violation.FieldPath == propertyPath) return true;
- }
-
- return false;
- }
-
- // An unassigned [SerializeReference] slot — a field whose pointer is the null sentinel (rid -2). Its band is
- // still a dropdown assigning a type through the live serialization API; a slot whose field path could not be
- // recovered stays static (nothing to target). A required slot wears the missing card's clothes — amber
- // "" header and amber band accent, no badge — so every "fix this" card in the graph reads the same.
- private VisualElement BuildEmptySlotCard(string assetPath, long fileId, string pathLabel)
- {
- var isRequired = IsFieldRequiredUnset(fileId, pathLabel);
-
- var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
- .AddClass(NodeClass);
- if (!isRequired) card.AddClass(NodeEmptyClass);
-
- // A plain Label on an ordinary empty slot so the --empty USS rule tints it; a required slot paints its
- // own amber status via AspidLabel, exactly like a missing card's type header.
- var typeLabel = isRequired
- ? (VisualElement)new AspidLabel(EmptySlotText, AspidLabelPreset.Default
- .SetLabelStatus(StatusStyle.Type.Warning)
- .SetLabelSize(AspidLabelSizeStyle.Type.H5)
- .SetLineSize(AspidDividingLineSizeStyle.Type.None))
- .AddClass(NodeTypeClass)
- : new Label(EmptySlotText).AddClass(NodeTypeClass);
- typeLabel.SetPickingMode(PickingMode.Ignore);
- if (isRequired) typeLabel.tooltip = "Required reference is not set";
-
- var bandRow = new VisualElement()
- .AddClass(NodeBandRowClass)
- .AddChild(typeLabel);
- bandRow.pickingMode = PickingMode.Ignore;
-
- if (string.IsNullOrEmpty(pathLabel))
- {
- // No recoverable field path to target — leave the slot a static "" leaf.
- card.AddChild(bandRow);
- AddBandDivider(card, band: null, sweepModifier: null);
- }
- else
- {
- // is a no-op here — the slot is already unset.
- var graphPath = pathLabel;
- AspidGradientButton band = null;
- band = new AspidGradientButton(isRequired ? AssignRequiredCollapsedText : AssignCollapsedText,
- _ => OpenLivePicker(assetPath, fileId, graphPath, band))
- .AddClass(NodeBandClass);
- if (isRequired) band.AddClass(NodeBandMissingClass);
- band.AddLeadingContent(bandRow);
- card.AddChild(band);
- RegisterNavTarget(band, () => OpenLivePicker(assetPath, fileId, graphPath, band));
- AddBandDivider(card, band, isRequired ? NodeSweepMissingClass : null);
- }
-
- var meta = new VisualElement().AddClass(NodeFooterClass);
-
- if (!string.IsNullOrEmpty(pathLabel))
- {
- meta.AddChild(MakeSelectable(new Label($"{pathLabel}:")
- .AddClass(NodeRootLabelClass)));
- }
-
- meta.AddChild(MakeSelectable(new Label("unassigned")
- .AddClass(NodeRidClass)));
-
- card.AddChild(meta);
-
- return card;
- }
-
- // Trailing cards for required violations the graph has no node for — a string / SerializableType required
- // field is never threaded into RefIds, so SerializeReferenceGraphScanner never emits a document for a
- // component whose only serialized-reference-worthy fields are these.
- // Mirrors a required BuildEmptySlotCard line for line — amber "" header, amber "Assign ▼" band,
- // "Component.field:" + "unassigned" on the footer — so a required string / SerializableType field reads
- // exactly like a required managed-reference slot (and both echo the missing card's clothes). The pick writes
- // the type's assembly-qualified name into the backing string; a scene asset cannot be object-loaded (see
- // TryResolveRequiredStringProperty), so its band stays a static line edited through the normal Inspector.
- private VisualElement BuildRequiredOnlyCard(GateViolation violation, Dictionary componentCache)
- {
- var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
- .AddClass(NodeClass);
-
- var typeLabel = new AspidLabel(EmptySlotText, AspidLabelPreset.Default
- .SetLabelStatus(StatusStyle.Type.Warning)
- .SetLabelSize(AspidLabelSizeStyle.Type.H5)
- .SetLineSize(AspidDividingLineSizeStyle.Type.None))
- .AddClass(NodeTypeClass)
- .SetPickingMode(PickingMode.Ignore);
- typeLabel.tooltip = "Required type is not set";
-
- var bandRow = new VisualElement()
- .AddClass(NodeBandRowClass)
- .AddChild(typeLabel);
- bandRow.pickingMode = PickingMode.Ignore;
-
- if (SerializeReferenceHelpers.IsScene(violation.AssetPath))
- {
- // Not reachable through the live serialization API — leave the band a static "" line.
- card.AddChild(bandRow);
- AddBandDivider(card, band: null, sweepModifier: null);
- }
- else
- {
- AspidGradientButton band = null;
- band = new AspidGradientButton(AssignRequiredCollapsedText, _ => OpenRequiredStringPicker(violation, band))
- .AddClass(NodeBandClass)
- .AddClass(NodeBandMissingClass);
- band.AddLeadingContent(bandRow);
- card.AddChild(band);
- RegisterNavTarget(band, () => OpenRequiredStringPicker(violation, band));
- AddBandDivider(card, band, NodeSweepMissingClass);
- }
-
- var component = ResolveComponentName(violation, componentCache);
- var text = string.IsNullOrEmpty(component) ? violation.FieldPath : $"{component}.{violation.FieldPath}";
-
- var meta = new VisualElement().AddClass(NodeFooterClass);
- meta.AddChild(MakeSelectable(new Label($"{text}:")
- .AddClass(NodeRootLabelClass)));
- meta.AddChild(MakeSelectable(new Label("unassigned")
- .AddClass(NodeRidClass)));
- card.AddChild(meta);
-
- return card;
- }
-
- // Constraint and current value are read from the live string property; a field the API cannot reach opens an
- // unconstrained picker and surfaces the failure on apply (mirrors OpenLivePicker).
- private void OpenRequiredStringPicker(GateViolation violation, AspidGradientButton anchor)
- {
- var filter = default(TypeSelectorFilter);
- var currentAqn = string.Empty;
-
- if (TryResolveRequiredStringProperty(violation, out var serializedObject, out var property))
- using (serializedObject)
- {
- currentAqn = property.stringValue ?? string.Empty;
- filter = BuildRequiredStringFilter(serializedObject, property);
- }
-
- TogglePicker(anchor, filter, currentAqn,
- assemblyQualifiedName => ApplyRequiredString(violation, assemblyQualifiedName));
- }
-
- // The same candidate set the field's own [TypeSelector] dropdown offers: the attribute's constraints resolved
- // member-first against the owning object (TypeSelectorConstraintResolver), the wrapper's T for a
- // SerializableType field, and the attribute's kind filter. Resolution warnings are the Inspector notice's
- // concern — here an unresolvable constraint just widens the picker.
- private static TypeSelectorFilter BuildRequiredStringFilter(SerializedObject serializedObject, SerializedProperty property)
- {
- if (!SerializeReferenceRequiredGate.TryGetRequired(property, out var selector)) return default;
-
- var types = new List();
-
- // The backing string of a SerializableType wrapper carries the wrapper's generic constraint.
- var path = property.propertyPath;
- var lastDotIndex = path.LastIndexOf('.');
- if (lastDotIndex >= 0)
- {
- using var parentProperty = serializedObject.FindProperty(path[..lastDotIndex]);
- var parentField = parentProperty?.GetFieldInfo();
- if (parentField is not null &&
- SerializableTypeUtility.TryGetBaseType(parentField.FieldType, out var wrapperBase) &&
- wrapperBase is not null && wrapperBase != typeof(object))
- types.Add(wrapperBase);
- }
-
- types.AddRange(TypeSelectorConstraintResolver.Resolve(
- serializedObject.targetObject, selector.AssemblyQualifiedNames).Types);
-
- return new TypeSelectorFilter
- {
- Types = types.Count > 0 ? types.ToArray() : null,
- Allow = selector.Allow,
- };
- }
-
- private void ApplyRequiredString(GateViolation violation, string assemblyQualifiedName)
- {
- // A non-empty name that fails to load is an unresolved pick, not a clear — leave the field untouched.
- // (empty) writes an empty name: for a required field that just keeps the violation visible.
- if (!string.IsNullOrEmpty(assemblyQualifiedName) &&
- Type.GetType(assemblyQualifiedName, throwOnError: false) is null)
- return;
-
- if (!TryResolveRequiredStringProperty(violation, out var serializedObject, out var property))
- {
- EditorUtility.DisplayDialog(
- "Assign Required Type",
- "This field cannot be edited here — it is not reachable through the serialization API. " +
- "Edit it in the Inspector instead.",
- "OK");
- return;
- }
-
- using (serializedObject)
- {
- property.SetStringAndApply(assemblyQualifiedName ?? string.Empty);
-
- var target = serializedObject.targetObject;
- EditorUtility.SetDirty(target);
- PersistEdit(violation.AssetPath, target);
- }
-
- SerializeReferenceYamlProbeCache.ClearCache();
- Rescan();
- }
-
- // Resolves the live document at the violation's file id and the string property at its field path — which is
- // already a SerializedProperty path (the gate scanner records iterator.propertyPath verbatim), so unlike
- // TryResolveLiveProperty no graph-path conversion applies. Returns false for a scene asset (not object-loadable).
- private static bool TryResolveRequiredStringProperty(GateViolation violation,
- out SerializedObject serializedObject, out SerializedProperty property)
- {
- serializedObject = null;
- property = null;
-
- if (SerializeReferenceHelpers.IsScene(violation.AssetPath)) return false;
-
- foreach (var obj in AssetDatabase.LoadAllAssetsAtPath(violation.AssetPath))
- {
- if (obj == null) continue;
- if (!AssetDatabase.TryGetGUIDAndLocalFileIdentifier(obj, out _, out var id) || id != violation.FileId) continue;
-
- var serialized = new SerializedObject(obj);
- var found = serialized.FindProperty(violation.FieldPath);
- if (found is { propertyType: SerializedPropertyType.String })
- {
- serializedObject = serialized;
- property = found;
- return true;
- }
-
- // The document matched but the path did not resolve to a string — no other document shares this
- // file id, so bail rather than scan on.
- serialized.Dispose();
- return false;
- }
-
- return false;
- }
-
- // Best-effort owning-object type name, mirroring SerializeReferenceProjectView's resolver: saved assets are
- // object-loaded (once per distinct path, memoised in componentCache) and matched by file id. Scenes cannot
- // be object-loaded (see SerializeReferenceHelpers.IsScene), so a scene row shows the field path alone.
- private static string ResolveComponentName(GateViolation violation, Dictionary componentCache)
- {
- if (SerializeReferenceHelpers.IsScene(violation.AssetPath)) return string.Empty;
-
- if (!componentCache.TryGetValue(violation.AssetPath, out var assets))
- {
- assets = AssetDatabase.LoadAllAssetsAtPath(violation.AssetPath);
- componentCache[violation.AssetPath] = assets;
- }
-
- foreach (var asset in assets)
- {
- if (asset == null) continue;
- if (AssetDatabase.TryGetGUIDAndLocalFileIdentifier(asset, out _, out long fileId) && fileId == violation.FileId)
- return asset.GetType().Name;
- }
-
- return string.Empty;
- }
-
- // Warning-tinted group for rids no root reaches. Each orphan is a full node card (so a missing orphan is still
- // fixable inline) with a footer Clear, without recursion into children.
- private VisualElement BuildOrphanGroup(string assetPath, ReferenceGraphDocument document)
- {
- if (document.Orphans.Count == 0) return null;
-
- var group = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
- .AddClass(OrphanGroupClass);
-
- group.AddChild(new AspidLabel("Orphaned", AspidLabelPreset.Default
- .SetLabelStatus(StatusStyle.Type.Warning)
- .SetLabelSize(AspidLabelSizeStyle.Type.H5)
- .SetLineSize(AspidDividingLineSizeStyle.Type.None))
- .AddClass(OrphanGroupHeaderClass));
-
- foreach (var node in document.Nodes)
- {
- if (!document.Orphans.Contains(node.Rid)) continue;
- group.AddChild(BuildNodeCard(assetPath, document, node, node.Rid, pathLabel: null, isOrphan: true));
- }
-
- return group;
- }
-
- private void ClearOrphan(string assetPath, long fileId, long rid)
- {
- if (BlockedByOpenCopy(assetPath)) return;
-
- if (!EditorUtility.DisplayDialog(
- "Drop Orphaned Entry",
- $"Remove the orphaned managed-reference entry (rid {rid}) from\n{assetPath}?\n\n" +
- "This edits the asset file directly and cannot be undone.",
- "Remove", "Cancel"))
- return;
-
- // Guard against a stale graph: confirm the rid is still an orphan against a fresh scan before deleting.
- var fresh = SerializeReferenceGraphScanner.Build(assetPath);
- var stillOrphan = false;
- foreach (var document in fresh)
- if (document.FileId == fileId && document.Orphans.Contains(rid)) { stillOrphan = true; break; }
-
- if (!stillOrphan)
- {
- // The on-screen graph was stale (the rid is no longer an orphan); re-render from the scan we just built
- // instead of reading the unchanged file a second time.
- Rescan(fresh);
- return;
- }
-
- if (!SerializeReferenceYamlEditor.TryRemoveEntry(assetPath, fileId, rid)) return;
-
- // The forced import lets the index invalidator patch this one asset surgically — a full ClearCache here
- // would dump the whole warm index and put Project References back on its modal first-scan.
- AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
- SerializeReferenceRepairSuggestions.ClearCache();
- Rescan();
- }
-
- // Missing card: opens the YAML fix picker, constrained to the rid's declared field type so a repair cannot
- // pick an incompatible type that would null on import; an unresolvable field type falls back to unconstrained.
- private void OpenMissingPicker(string assetPath, long fileId, long rid, AspidGradientButton anchor) =>
- TogglePicker(anchor, BuildManagedReferenceFilter(ResolveConstraint(assetPath, fileId, rid)),
- currentAqn: null, // a missing entry has no current value — nothing (not even ) wears the check
- assemblyQualifiedName => ApplyFix(assetPath, fileId, rid, assemblyQualifiedName));
-
- // Healthy / empty card: constraint and current type are read from the live property at the field path. A field
- // the API cannot reach opens an unconstrained picker and surfaces the failure on apply.
- private void OpenLivePicker(string assetPath, long fileId, string graphPath, AspidGradientButton anchor)
- {
- Type constraint = typeof(object);
- var currentAqn = string.Empty;
-
- if (TryResolveLiveProperty(assetPath, fileId, graphPath, out var serializedObject, out var property))
- using (serializedObject)
- {
- constraint = SerializeReferenceHelpers.GetFieldType(property);
- currentAqn = property.managedReferenceValue?.GetType().AssemblyQualifiedName ?? string.Empty;
- }
-
- TogglePicker(anchor, BuildManagedReferenceFilter(constraint), currentAqn,
- assemblyQualifiedName => ApplyLive(assetPath, fileId, graphPath, assemblyQualifiedName));
- }
-
- // The candidate filter every managed-reference picker shares: concrete types assignable to the field's declared
- // type, plus the open generic definitions that can close over it. An unresolvable constraint falls back to
- // unconstrained (any managed-reference type).
- private static TypeSelectorFilter BuildManagedReferenceFilter(Type constraint)
- {
- var baseType = constraint ?? typeof(object);
-
- return new TypeSelectorFilter
- {
- Types = new[] { baseType },
- Predicate = SerializeReferenceHelpers.IsAssignableManagedReference,
- AdditionalTypes = baseType == typeof(object) ? null : GenericTypeResolver.GetAssignableGenericDefinitions(baseType),
- ArgumentFilter = SerializeReferenceHelpers.IsValidGenericArgument,
- };
- }
-
- // The picker expands inline under the clicked card's band, one panel at a time. Generic over the source of
- // truth: the caller supplies the candidate filter, the type to pre-navigate to, and what a pick does.
- private void TogglePicker(AspidGradientButton anchor, TypeSelectorFilter filter, string currentAqn, Action onSelected)
- {
- var wasOpen = _openPickerRow == anchor;
- ClosePicker();
- if (wasOpen) return;
-
- var view = new TypeSelectorView(
- filter: filter,
- currentAqn: currentAqn, // null (no current-value concept) and "" (holds ) both pass through as-is
- onSelected: onSelected,
- onDismiss: ClosePicker);
-
- _openPicker = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
- .AddClass(PickerClass)
- .AddChild(view);
-
- _openPickerRow = anchor;
- // Glyph-only swap (▼→▲) so the same replace works for every band label.
- if (anchor is not null) anchor.Text = anchor.Text.Replace(BandChevronCollapsed, BandChevronExpanded);
-
- // Drop the picker directly below the band inside the card (the ?? fallback keeps a sane target if the
- // band is ever hosted outside a card). The band's divider + sweep hide while the card is picking (USS
- // --picking rules), and the action / footer rows sit below the docked selector — mirroring the Project
- // References card whose picker docks under the header with the entry rows below it.
- var card = anchor?.parent;
- var container = card ?? _list;
- container.InsertChild(container.IndexOf(anchor) + 1, _openPicker);
-
- if (card is not null)
- {
- _openPickerCard = card;
- _openPickerCard.AddClass(NodePickingClass);
- _openPicker.AddClass(PickerAttachedClass);
- }
-
- view.FocusPicker();
- }
-
- private void ClosePicker()
- {
- _openPicker?.RemoveFromHierarchy();
- // Glyph-only swap (▲→▼) so the band's label is preserved.
- if (_openPickerRow is not null)
- _openPickerRow.Text = _openPickerRow.Text.Replace(BandChevronExpanded, BandChevronCollapsed);
- _openPickerCard?.RemoveClass(NodePickingClass);
-
- _openPicker = null;
- _openPickerRow = null;
- _openPickerCard = null;
-
- // The dismissed picker leaves keyboard focus dangling on its (removed) search field; reclaim it so the
- // arrow-key ring keeps working. Guarded — ClosePicker also runs from render paths before attach.
- if (panel is not null) Focus();
- }
-
- private void ApplyFix(string assetPath, long fileId, long rid, string assemblyQualifiedName)
- {
- if (BlockedByOpenCopy(assetPath)) return;
-
- // emits an empty name: clear the reference (dropping the broken payload) rather than letting it
- // fall through to the null-type guard below as a silent no-op.
- if (string.IsNullOrEmpty(assemblyQualifiedName))
- {
- ClearReference(assetPath, fileId, rid);
- return;
- }
-
- var type = Type.GetType(assemblyQualifiedName, throwOnError: false);
- if (type is null) return;
-
- // Rewrite only the captured file id's document: a rid is unique within a document but can collide across
- // documents, so looping the asset's documents could rewrite a healthy reference that shares the rid.
- if (!SerializeReferenceYamlEditor.TryRewriteType(assetPath, fileId, rid, ManagedTypeName.FromType(type)))
- return;
-
- AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
- SerializeReferenceRepairSuggestions.ClearCache();
- Rescan();
- }
-
- // Resets a missing reference to in the YAML (a missing reference cannot be cleared through the
- // serialization API): nulls every pointer to Unity's null sentinel (-2) and drops the RefIds entry — exactly
- // what Unity writes for a cleared field. Confirmed and not undoable; the broken payload is discarded.
- private void ClearReference(string assetPath, long fileId, long rid)
- {
- if (BlockedByOpenCopy(assetPath)) return;
-
- // Name how many fields the clear will null so an aliased reference doesn't silently take down siblings.
- // A non-positive count means the pointers couldn't be located — use the unnumbered wording, not "0 fields".
- var fieldCount = SerializeReferenceYamlEditor.CountPointersTo(assetPath, fileId, rid);
- var pointerLine = fieldCount switch
- {
- 1 => "This nulls the 1 field pointing at it",
- > 1 => $"This reference is aliased across {fieldCount} fields — clearing it nulls every one of them",
- _ => "This nulls every field pointing at it",
- };
-
- if (!EditorUtility.DisplayDialog(
- "Clear Reference",
- $"Reset this managed reference (rid {rid}) to in\n{assetPath}?\n\n" +
- $"{pointerLine} and discards its stored data. It edits the asset file directly and cannot be undone.",
- "Clear", "Cancel"))
- return;
-
- if (!SerializeReferenceYamlEditor.TryNullReference(assetPath, fileId, rid)) return;
-
- // Surgical index patch via the import invalidator, not a full ClearCache (see ClearOrphan).
- AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
- SerializeReferenceRepairSuggestions.ClearCache();
- Rescan();
- }
-
- // Edits a healthy / empty slot through SerializedProperty.managedReferenceValue, so Unity creates / rewrites /
- // removes the RefIds entry exactly as the Inspector would. The asset is saved to disk so the disk-read graph
- // reflects the edit on rescan; a path the API cannot reach is reported and skipped.
- private void ApplyLive(string assetPath, long fileId, string graphPath, string assemblyQualifiedName)
- {
- var type = string.IsNullOrEmpty(assemblyQualifiedName)
- ? null
- : Type.GetType(assemblyQualifiedName, throwOnError: false);
-
- // A non-empty name that fails to load is an unresolved pick, not a clear — leave the slot untouched rather
- // than silently nulling it.
- if (!string.IsNullOrEmpty(assemblyQualifiedName) && type is null) return;
-
- if (!TryResolveLiveProperty(assetPath, fileId, graphPath, out var serializedObject, out var property))
- {
- EditorUtility.DisplayDialog(
- "Edit Reference",
- "This slot cannot be edited here — its field is not reachable through the serialization API " +
- "(it may be an orphan, live in a scene, or sit under a missing parent). Edit it in the Inspector " +
- "or repair its parent first.",
- "OK");
- return;
- }
-
- using (serializedObject)
- {
- var previous = property.managedReferenceValue;
- // type == null clears to ; a concrete type carries over the previous value's matching fields.
- property.SetManagedReferenceAndApply(SerializeReferenceHelpers.CreateInstancePreservingData(type, previous));
- property.isExpanded = type is not null;
-
- var target = serializedObject.targetObject;
- EditorUtility.SetDirty(target);
- PersistEdit(assetPath, target);
- }
-
- // PersistEdit's save triggers the import that lets the index invalidator patch this asset surgically —
- // no full ClearCache (see ClearOrphan).
- SerializeReferenceRepairSuggestions.ClearCache();
- SerializeReferenceYamlProbeCache.ClearCache();
- Rescan();
- }
-
- // A file rewrite is only safe when the asset is not loaded as a scene and not open in Prefab Mode — the open
- // in-memory copy would win on its next save, silently clobbering the fix. Same test as IsEntryWritable.
- private static bool BlockedByOpenCopy(string assetPath)
- {
- var openInScene = UnityEngine.SceneManagement.SceneManager.GetSceneByPath(assetPath).isLoaded;
- var stagePath = PrefabStageUtility.GetCurrentPrefabStage()?.assetPath;
- var openInPrefabMode = !string.IsNullOrEmpty(stagePath) &&
- string.Equals(stagePath, assetPath, StringComparison.Ordinal);
-
- if (!openInScene && !openInPrefabMode) return false;
-
- EditorUtility.DisplayDialog(
- "Asset References",
- "This asset is open " + (openInPrefabMode ? "in Prefab Mode" : "as a loaded scene") +
- " — a file rewrite would be overwritten by its next save.\n\n" +
- "Close it and rescan, or repair the field directly in the Inspector.",
- "OK");
- return true;
- }
-
- // A prefab component edit does not reliably flush through the generic asset-dirty path (the prefab pipeline
- // owns its serialization), so prefabs save via SavePrefabAsset on the in-memory root; anything else via
- // SaveAssetIfDirty.
- private static void PersistEdit(string assetPath, Object target)
- {
- var prefabRoot = AssetDatabase.LoadAssetAtPath(assetPath);
- if (prefabRoot != null) PrefabUtility.SavePrefabAsset(prefabRoot);
- else AssetDatabase.SaveAssetIfDirty(target);
- }
-
- // Resolves the live document at fileId and the managed-reference property at graphPath (list indices expanded
- // to Unity's ".Array.data[i]" form). The caller disposes the returned SerializedObject; returns false for a
- // path the API cannot reach (an empty path, a scene asset, or a field under a missing/null parent).
- private static bool TryResolveLiveProperty(string assetPath, long fileId, string graphPath,
- out SerializedObject serializedObject, out SerializedProperty property)
- {
- serializedObject = null;
- property = null;
-
- if (string.IsNullOrEmpty(graphPath)) return false;
- // Scenes are not loadable through LoadAllAssetsAtPath (see SerializeReferenceHelpers.IsScene).
- if (SerializeReferenceHelpers.IsScene(assetPath)) return false;
-
- var propertyPath = ToSerializedPropertyPath(graphPath);
-
- foreach (var obj in AssetDatabase.LoadAllAssetsAtPath(assetPath))
- {
- if (obj == null) continue;
- if (!AssetDatabase.TryGetGUIDAndLocalFileIdentifier(obj, out _, out var id) || id != fileId) continue;
-
- var serialized = new SerializedObject(obj);
- var found = serialized.FindProperty(propertyPath);
- if (found is { propertyType: SerializedPropertyType.ManagedReference })
- {
- serializedObject = serialized;
- property = found;
- return true;
- }
-
- // The document matched but the path did not resolve to a managed reference — no other document shares
- // this file id, so bail rather than scan on.
- serialized.Dispose();
- return false;
- }
-
- return false;
- }
-
- // "name[i]" becomes Unity's "name.Array.data[i]" — the inverse of the ".Array.data" stripping
- // SerializeReferenceYamlEditor does when it normalises a property path.
- private static string ToSerializedPropertyPath(string graphPath) =>
- Regex.Replace(graphPath, @"\[(\d+)\]", ".Array.data[$1]");
-
- // Recovers the declared field type backing rid through the per-asset constraint-map cache (one scan shared by
- // every picker open), keyed by exact (fileId, rid) since rids collide across documents. Returns null
- // (unconstrained) for an orphaned payload or an unresolvable field type.
- private Type ResolveConstraint(string assetPath, long fileId, long rid)
- {
- if (!_constraintCache.TryGetValue(assetPath, out var map))
- {
- map = SerializeReferenceHelpers.BuildConstraintMap(assetPath);
- _constraintCache[assetPath] = map;
- }
-
- return map.TryGetValue((fileId, rid), out var constraint) ? constraint : null;
- }
-
- // Future work: a "Make unique" action on a SHARED node — cloning the aliased reference so the two fields no
- // longer affect each other (mirrors SerializeReferenceHelpers.MakeReferenceUnique).
- }
-}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceProjectView.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceProjectView.cs
deleted file mode 100644
index 2556b191..00000000
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceProjectView.cs
+++ /dev/null
@@ -1,1417 +0,0 @@
-using System;
-using System.Linq;
-using UnityEngine;
-using UnityEditor;
-using UnityEngine.UIElements;
-using Aspid.FastTools.UIElements;
-using System.Collections.Generic;
-using Aspid.FastTools.Types.Editors;
-using Aspid.FastTools.UIElements.Editors.Internal;
-using Object = UnityEngine.Object;
-using static Aspid.FastTools.SerializeReferences.Editors.SerializeReferenceAuditUI;
-
-// ReSharper disable once CheckNamespace
-namespace Aspid.FastTools.SerializeReferences.Editors
-{
- ///
- /// Repair tool for missing [SerializeReference] types. It runs in two modes that share one results list:
- ///
- /// Single asset — assigning the asset field scans that one file and lists every orphaned managed
- /// reference (at any nesting depth, on any child object) as a full-width Fix row, fixed by rewriting the stored
- /// type directly in the YAML so it never needs Prefab Mode.
- /// Project — the Scan Project button sweeps every text asset under Assets/ , groups the
- /// broken references by their stored (now unloadable) type, and offers a single bulk Fix all per group:
- /// one type pick + one confirmation rewrites every entry across every affected file.
- ///
- ///
- internal sealed class SerializeReferenceProjectView : VisualElement
- {
- private const string StyleSheetPath = "UI/SerializeReferences/Aspid-FastTools-SerializeReference";
-
- private const string RootClass = "aspid-fasttools-repair-references";
- private const string ContentClass = RootClass + "__content";
- private const string PanelClass = RootClass + "__panel";
- private const string PanelTitleClass = RootClass + "__panel-title";
- private const string PanelDescriptionClass = RootClass + "__panel-description";
- private const string ScanProjectClass = RootClass + "__scan-project";
- private const string EmptyClass = RootClass + "__empty";
- private const string EmptyHiddenClass = EmptyClass + "--hidden";
- private const string EmptyIconClass = RootClass + "__empty-icon";
- private const string EmptyIconInfoClass = EmptyIconClass + "--info";
- private const string EmptyIconSuccessClass = EmptyIconClass + "--success";
- private const string EmptyTitleClass = RootClass + "__empty-title";
- private const string EmptyMessageClass = RootClass + "__empty-message";
- private const string ResultsClass = RootClass + "__results";
- private const string ResultsHiddenClass = ResultsClass + "--hidden";
- private const string ResultsHeaderClass = RootClass + "__results-header";
- private const string ResultsHintClass = RootClass + "__results-hint";
- private const string LegendClass = RootClass + "__legend";
- private const string LegendHiddenClass = LegendClass + "--hidden";
- private const string LegendItemClass = RootClass + "__legend-item";
- private const string LegendDotClass = RootClass + "__legend-dot";
- private const string LegendDotInfoClass = LegendDotClass + "--info";
- private const string LegendTextClass = RootClass + "__legend-text";
- private const string SummaryListClass = RootClass + "__summary-list";
- private const string SummaryClass = RootClass + "__summary";
- private const string SummaryUndoClass = RootClass + "__summary-undo";
- private const string ScrollClass = RootClass + "__scroll";
- private const string PickerClass = RootClass + "__picker";
- private const string PickerAttachedClass = PickerClass + "--attached";
-
- private const string GroupClass = RootClass + "__group";
- private const string GroupMigrateClass = GroupClass + "--migrate";
- private const string GroupPickingClass = GroupClass + "--picking";
- private const string GroupHeaderHoverClass = GroupClass + "--header-hover";
- private const string GroupDividerClass = RootClass + "__group-divider";
- private const string GroupSweepClass = RootClass + "__group-sweep";
- private const string GroupSweepMigrateClass = GroupSweepClass + "--migrate";
- private const string GroupHeaderRowClass = RootClass + "__group-header-row";
- private const string GroupHeaderRowStaticClass = GroupHeaderRowClass + "--static";
- private const string GroupHeaderClass = RootClass + "__group-header";
- private const string GroupCountClass = RootClass + "__group-count";
- private const string GroupFixAllClass = RootClass + "__group-fix-all";
- private const string GroupFixAllMigrateClass = GroupFixAllClass + "--migrate";
- private const string GroupActionClass = RootClass + "__group-action";
- private const string GroupActionInfoClass = GroupActionClass + "--info";
- private const string GroupEntryClass = RootClass + "__group-entry";
- private const string GroupEntryPathClass = RootClass + "__group-entry-path";
- private const string GroupEntryRidClass = RootClass + "__group-entry-rid";
- private const string GroupEntryFieldClass = RootClass + "__group-entry-field";
- private const string NavTargetClass = RootClass + "__nav-target";
- private const string NavTargetFocusedClass = NavTargetClass + "--focused";
-
- // Chevron on the "Fix all (N)" dropdown button; only the glyph differs between the two states.
- private const string FixArrowCollapsed = "▼";
- private const string FixArrowExpanded = "▲";
-
- // Scan button label: cold call-to-action before the first scan, quiet refresh once the index is warm.
- private const string ScanLabel = "Scan Project";
- private const string RescanLabel = "Rescan";
-
- private readonly VisualElement _empty;
- private readonly VisualElement _results;
- private readonly AspidLabel _resultsHeader;
- private readonly VisualElement _summaries;
- private readonly Label _resultsHint;
- private readonly VisualElement _legend;
- private readonly VisualElement _list;
- private VisualElement _openPicker;
- private AspidGradientButton _openPickerRow;
- private VisualElement _openPickerCard;
- private readonly AspidGradientButton _scanButton;
- private readonly ScrollView _scroll;
-
- // Keyboard navigation: one flat focus ring over every actionable element in visual order — Rescan first,
- // then each card's Fix all / action row / entry rows — shared with the other window tabs.
- private readonly NavRing _ring;
-
- // The legend's block-specific USS class names for the shared item builder.
- private static readonly LegendClasses LegendClassSet = new(LegendItemClass, LegendDotClass, LegendDotInfoClass, LegendTextClass);
-
- // Required-violations audit has no incrementally-maintained index like SerializeReferenceTypeUsageIndex, so it
- // is only (re)scanned on an explicit Scan/Rescan click, not on every Initialize() (tab switch would otherwise
- // pay for a full project sweep). Static so the result survives the view being rebuilt on a tab switch.
- private static bool _requiredIsWarm;
- private static IReadOnlyList _requiredViolationsCache = Array.Empty();
-
- private static IReadOnlyList RequiredViolationsForRender =>
- _requiredIsWarm ? _requiredViolationsCache : Array.Empty();
-
- ///
- /// Jump from a project-audit result row to that asset's Inspect graph. Wired by the host window.
- ///
- public Action OnInspectAsset;
-
- ///
- /// Reports this view's state-tone to the host window, which owns the shared dotted canvas. Wired by the window.
- ///
- public Action OnCanvasTone;
-
- public SerializeReferenceProjectView()
- {
- var root = this;
- style.flexGrow = 1;
- root.AddAspidThemeStyleSheets()
- .AddStyleSheetsFromResource(StyleSheetPath)
- .AddClass(RootClass);
-
- var panelTitle = new AspidLabel("Find missing references", AspidLabelPreset.Default
- .SetLabelTheme(ThemeStyle.Type.Lightness)
- .SetLabelSize(AspidLabelSizeStyle.Type.H5)
- .SetLineSize(AspidDividingLineSizeStyle.Type.None))
- .AddClass(PanelTitleClass);
-
- var panelDescription = new Label(
- "Sweep every asset under Assets/ for broken [SerializeReference] types and bulk-fix them by type.")
- .AddClass(PanelDescriptionClass);
-
- // Label flips between ScanLabel and RescanLabel as the index warms.
- _scanButton = new AspidGradientButton(ScanLabel, _ => ScanProject())
- .AddClass(ScanProjectClass);
-
- var panel = new VisualElement()
- .AddClass(PanelClass)
- .AddChild(panelTitle)
- .AddChild(panelDescription)
- .AddChild(_scanButton);
-
- _empty = new VisualElement().AddClass(EmptyClass);
-
- _resultsHeader = new AspidLabel(string.Empty, AspidLabelPreset.Default
- .SetLabelStatus(StatusStyle.Type.Warning)
- .SetLabelSize(AspidLabelSizeStyle.Type.H4)
- .SetLineTheme(ThemeStyle.Type.Dark)
- .SetLineStatus(StatusStyle.Type.Warning))
- .AddClass(ResultsHeaderClass);
-
- _resultsHint = new Label(string.Empty).AddClass(ResultsHintClass);
-
- // Color key for the two card accents; only shown when both are actually on screen (see RenderGroups).
- _legend = new VisualElement()
- .AddClass(LegendClass)
- .AddClass(LegendHiddenClass)
- .AddChild(BuildLegendItem("Broken — pick a replacement", info: false, LegendClassSet))
- .AddChild(BuildLegendItem("Renamed — one-click migrate", info: true, LegendClassSet));
-
- // Receipt stack: one help-box per bulk Fix all, kept across chained fixes and cleared only on a fresh scan.
- _summaries = new VisualElement().AddClass(SummaryListClass);
-
- _list = new VisualElement();
-
- _results = new VisualElement()
- .AddClass(ResultsClass)
- .AddChild(_resultsHeader)
- .AddChild(_resultsHint)
- .AddChild(_legend)
- .AddChild(_summaries)
- .AddChild(_list);
-
- // One scroll spans the whole view, so the panel scrolls away with the group list instead of staying pinned.
- var content = new VisualElement()
- .AddClass(ContentClass)
- .AddChild(panel)
- .AddChild(_empty)
- .AddChild(_results);
-
- _scroll = new ScrollView().AddClass(ScrollClass);
- _scroll.AddChild(content);
-
- root.AddChild(_scroll);
-
- // The shared keyboard ring: the root holds focus (grabbed on attach, re-grabbed when the picker closes)
- // so keys reach it before anything is highlighted. Suspended while a type picker owns the keyboard.
- _ring = new NavRing(
- host: this,
- navTargetClass: NavTargetClass,
- paint: PaintNavFocus,
- scrollTo: element => _scroll.ScrollTo(element),
- isSuspended: () => _openPicker is not null);
-
- ResetNavTargets();
- }
-
- // ---------------------------------------------------------------------------------------------------------
- // Keyboard navigation
- // ---------------------------------------------------------------------------------------------------------
-
- // Gradient buttons paint their hover in code (accent overlay + tinted labels); the focused class's flat fill
- // would just show through their fading gradient as a gray pill, so they take ONLY the programmatic hover and
- // the plain rows take ONLY the class. A focused Fix all header also lights its card's divider sweep.
- private static void PaintNavFocus(VisualElement element, bool on)
- {
- if (element is AspidGradientButton button) button.Highlighted = on;
- else element.EnableInClassList(NavTargetFocusedClass, on);
- SetHeaderSweep(element, on);
- }
-
- // A focused Fix all header also lights its card's divider sweep — the same card-level hover mirror the
- // mouse path drives in CreateHeaderSweep — so keyboard focus and mouse hover render identically.
- private static void SetHeaderSweep(VisualElement element, bool on)
- {
- if (element.ClassListContains(GroupFixAllClass))
- element.parent?.EnableInClassList(GroupHeaderHoverClass, on);
- }
-
- // Every render pass rebuilds the ring from scratch (the old elements are gone with _list.Clear()). Rescan is
- // always slot 0; a highlight sitting on it survives the rebuild, so Enter-on-Rescan keeps its highlight.
- private void ResetNavTargets()
- {
- var keepScanFocus = _ring.Index == 0;
- _ring.Clear();
-
- RegisterNavTarget(_scanButton, ScanProject);
- if (keepScanFocus) _ring.Focus(0, scrollTo: false);
- }
-
- private void RegisterNavTarget(VisualElement element, Action activate) => _ring.Register(element, activate);
-
- // Cold index: open idle and wait for a deliberate Scan click — the cold sweep parses every asset's YAML behind
- // a blocking bar, so it must never run unasked. Warm index: re-deriving groups is a cheap in-memory filter, so
- // results survive a tab switch. The breakage-notification deep-link bypasses this and calls ScanProject directly.
- public void Initialize()
- {
- if (SerializeReferenceTypeUsageIndex.IsWarm || _requiredIsWarm) RenderWarmGroups();
- else ShowIdle();
- }
-
- // ---------------------------------------------------------------------------------------------------------
- // Project mode
- // ---------------------------------------------------------------------------------------------------------
-
- // Sweeps the project for missing references and groups them by stored broken type (slow when the index is cold).
- public void ScanProject()
- {
- if (_list is null) return;
-
- ClosePicker();
- ClearSummaries();
-
- // Unlike the missing-type index, the required-field scan has nothing incremental behind it — this is the
- // one deliberate moment it pays for a full project sweep (see RequiredViolationsForRender).
- _requiredViolationsCache = CollectRequiredViolations();
- _requiredIsWarm = true;
-
- RenderWarmGroups();
- }
-
- // Collects the unresolved set from the warm index and paints it; shared by Scan/Rescan and Initialize's warm restore.
- private void RenderWarmGroups()
- {
- if (_list is null) return;
- if (_scanButton is not null) _scanButton.Text = RescanLabel;
-
- var groups = CollectProjectGroups(out var canceled);
- RenderGroups(groups, RequiredViolationsForRender, canceled);
- }
-
- // Full project sweep for unset [TypeSelector(Required = true)] fields, reusing the same headless scanner the
- // build/CI gate uses. Skipped entirely when the gate is switched Off — a required audit nobody wants to fail
- // or warn on shouldn't cost a full-project YAML sweep on every Scan click either.
- private static IReadOnlyList CollectRequiredViolations() =>
- SerializeReferenceSettings.BuildSeverity == GateSeverity.Off
- ? Array.Empty()
- : SerializeReferenceGateScanner.Scan(GateOptions.RequiredOnly);
-
- // Paints a collected group set: count header + hint + one card per broken-type group plus one Required
- // violations card, or the terminal hero when both are empty. ApplyGroupFix/ClearGroupToNull special-case the
- // came-back-clean case so their summary HelpBox survives (see there).
- private void RenderGroups(List groups, IReadOnlyList requiredViolations, bool canceled)
- {
- _list.Clear();
- ResetNavTargets();
-
- var missingCount = groups.Sum(group => group.Entries.Count);
- var requiredCount = requiredViolations.Count;
-
- if (missingCount == 0 && requiredCount == 0)
- {
- ShowEmptyState(
- success: !canceled,
- title: canceled ? "Scan canceled" : "Project clean",
- message: canceled
- ? "The project scan was canceled before finding any missing references."
- : "No missing managed references or unset required fields found anywhere under Assets/.");
- return;
- }
-
- // Pending migrations sink to the very bottom, below the Required violations card too: the whole amber
- // band (broken groups, then required fields) stacks first and the calm blue one-click cards close the
- // list. Each band keeps the scanner's order.
- var migrations = new List<(ProjectGroup Group, GroupMigration Migration)>();
- foreach (var group in groups)
- {
- // Resolve constraint + migration ONCE per group and reuse it for the card and picker label below, so
- // the partition and the card can never disagree on whether a group is a migration.
- var migration = new GroupMigration(group);
- if (migration.IsMigration) migrations.Add((group, migration));
- else _list.AddChild(BuildGroupCard(group, migration));
- }
-
- // The header splits the migration entries out of the missing count — a [MovedFrom] rename with a
- // one-click fix shouldn't inflate the alarm number.
- var migrationCount = migrations.Sum(entry => entry.Group.Entries.Count);
- ShowResults(
- BuildResultsHeaderText(missingCount - migrationCount, migrationCount, requiredCount),
- SerializeReferenceCanvasStyle.Warning);
- _resultsHint.text = BuildResultsHintText(canceled, requiredCount > 0);
-
- // The amber/blue key only earns its row when both accents are on screen at once.
- var hasAmber = groups.Count > migrations.Count || requiredCount > 0;
- _legend.EnableInClassList(LegendHiddenClass, migrations.Count == 0 || !hasAmber);
-
- if (requiredCount > 0)
- _list.AddChild(BuildRequiredGroupCard(requiredViolations));
-
- foreach (var (group, migration) in migrations)
- _list.AddChild(BuildGroupCard(group, migration));
- }
-
- // Resolves a group's picker constraint and whether it reads as a one-click [MovedFrom] migration ONCE, so the
- // partition in RenderGroups, the card body and the picker label share one computation and can never disagree.
- // A migration is an authoritative [MovedFrom] rename whose target also fits the group's field constraint —
- // Migrate all bypasses the picker's assignability guarantee, and an incompatible target would be nulled by
- // Unity at load, so the constraint gate matters.
- private readonly struct GroupMigration
- {
- public readonly Type Constraint;
- public readonly bool IsMigration;
- public readonly Type Target; // the [MovedFrom] target when IsMigration; otherwise null.
-
- public GroupMigration(ProjectGroup group)
- {
- Constraint = group.ResolveConstraint();
- IsMigration = SerializeReferenceMovedFromResolver.TryResolve(group.StoredType, out var target) &&
- (Constraint == typeof(object) || Constraint.IsAssignableFrom(target));
- Target = IsMigration ? target : null;
- }
- }
-
- // Only non-zero parts make the header; brokenCount is the missing total MINUS the pending-migration entries,
- // which get their own calmer "pending migration" wording.
- private static string BuildResultsHeaderText(int brokenCount, int migrationCount, int requiredCount)
- {
- var parts = new List(3);
- if (brokenCount > 0) parts.Add(BuildCountText(brokenCount, "missing reference"));
- if (migrationCount > 0) parts.Add(BuildCountText(migrationCount, "pending migration"));
- if (requiredCount > 0) parts.Add(BuildCountText(requiredCount, "required violation"));
-
- return string.Join(", ", parts);
- }
-
- private static string BuildResultsHintText(bool canceled, bool hasRequiredViolations)
- {
- var hint = canceled
- ? "Scan canceled — showing partial results. Fix all re-points a group's every entry to one replacement, or to ."
- : "Each group is a broken stored type — Fix all re-points its every entry to one replacement, or to .";
-
- if (hasRequiredViolations)
- hint += " Click a required-violation row to jump to its asset.";
-
- return hint;
- }
-
- // Shared "no missing references left" branch for ApplyGroupFix/ClearGroupToNull: stays in the results region
- // (not the clean-state hero) so the fix's summary receipt survives, while still surfacing whatever Required
- // violations card RequiredViolationsForRender currently reports (empty right after ClearGroupToNull, which
- // invalidates the cache instead of risking a stale under-report — see its _requiredIsWarm = false).
- private void ShowMissingReferencesClean()
- {
- _list.Clear();
- ResetNavTargets();
- var requiredViolations = RequiredViolationsForRender;
-
- ShowResults(
- requiredViolations.Count == 0 ? "No missing references" : $"No missing references, {BuildCountText(requiredViolations.Count, "required violation")}",
- SerializeReferenceCanvasStyle.Success);
- _resultsHint.text = "Nothing left to repair. Rescan to sweep the project again and confirm it's clean.";
- _legend.AddClass(LegendHiddenClass);
-
- if (requiredViolations.Count > 0)
- _list.AddChild(BuildRequiredGroupCard(requiredViolations));
- }
-
- // Groups every unresolved managed reference by stored type, backed by the shared usage index. The out
- // parameter is kept for the call sites but is always false: the index warm-up runs to completion.
- private static List CollectProjectGroups(out bool canceled)
- {
- canceled = false;
- var byType = new Dictionary(StringComparer.Ordinal);
-
- foreach (var usage in SerializeReferenceTypeUsageIndex.EnumerateUnresolved())
- {
- var path = AssetDatabase.GUIDToAssetPath(usage.Guid);
- if (string.IsNullOrEmpty(path)) continue;
-
- var key = SerializeReferenceHelpers.StoredTypeKey(usage.StoredType);
- if (!byType.TryGetValue(key, out var group))
- {
- group = new ProjectGroup(usage.StoredType);
- byType.Add(key, group);
- }
-
- group.Add(path, new MissingReferenceEntry(usage.FileId, usage.Rid, usage.StoredType));
- }
-
- var groups = byType.Values.ToList();
- groups.Sort((a, b) => b.Entries.Count.CompareTo(a.Entries.Count));
- return groups;
- }
-
- // A broken-type group card: the whole header is one clickable row that toggles the type picker, with the bulk
- // "Fix all (N) ▼" action on the right. Entries are deliberately not individually fixable in project mode —
- // the per-row Fix affordance is reserved for single-asset mode.
- private VisualElement BuildGroupCard(ProjectGroup group, GroupMigration migration)
- {
- var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
- .AddClass(GroupClass);
-
- // Constraint + migration were resolved once in RenderGroups (see GroupMigration) and are reused here so
- // the card can never disagree with the partition. A migration is an authoritative [MovedFrom] rename:
- // Unity already migrates these in memory at load — only the files still store the old name — and its
- // target fits the group's field constraint (Migrate all bypasses the picker's assignability guarantee).
- var constraint = migration.Constraint;
- var displayName = group.DisplayName;
- var isMigration = migration.IsMigration;
- var migrationTarget = migration.Target;
-
- // Card-level modifier so card-wide states (the --picking accent frame) can follow the card's own
- // accent — a migration card is info-toned end to end, never the broken-card amber.
- if (isMigration) card.AddClass(GroupMigrateClass);
-
- // Built first so the type name + count can be docked into its body; the captured local is assigned before use.
- AspidGradientButton fixAll = null;
- fixAll = new AspidGradientButton(BuildFixAllLabel(group, expanded: false, isMigration),
- _ => ToggleGroupPicker(group, constraint, fixAll, isMigration))
- .AddClass(GroupFixAllClass);
- // A migration card keeps its calm info tone end to end — the amber Fix all accent is the "broken" alarm.
- if (isMigration) fixAll.AddClass(GroupFixAllMigrateClass);
- RegisterNavTarget(fixAll, () => ToggleGroupPicker(group, constraint, fixAll, isMigration));
- fixAll.tooltip = constraint == typeof(object)
- ? $"{displayName}\nMixed or unresolvable field types — the picker is unconstrained (any managed-reference type)."
- : $"{displayName}\nConstrained to {constraint.FullName}.";
-
- // The type name + count line, ignored for picking so clicks fall through to the button's own handler.
- var header = new AspidLabel(group.StoredType.Class, AspidLabelPreset.Default
- .SetLabelStatus(isMigration ? StatusStyle.Type.Info : StatusStyle.Type.Warning)
- .SetLabelSize(AspidLabelSizeStyle.Type.H5)
- .SetLineSize(AspidDividingLineSizeStyle.Type.None))
- .AddClass(GroupHeaderClass)
- .SetPickingMode(PickingMode.Ignore);
-
- var count = new Label(BuildGroupCountText(group))
- .AddClass(GroupCountClass)
- .SetPickingMode(PickingMode.Ignore);
-
- var info = new VisualElement()
- .AddClass(GroupHeaderRowClass)
- .AddChild(header)
- .AddChild(count);
- info.pickingMode = PickingMode.Ignore;
-
- fixAll.AddLeadingContent(info);
- card.AddChild(fixAll);
-
- // Header divider + its underline sweep (the Welcome cards' idiom): the sweep rides the divider line,
- // amber for a broken group, the calm info tone on a migration card. Both hide while the picker is
- // docked — the dropdown is inserted right after the header and they would land under it.
- card.AddChild(new AspidDividingLine(AspidDividingLinePreset.Default
- .SetTheme(ThemeStyle.Type.Light)
- .SetSize(AspidDividingLineSizeStyle.Type.Thin))
- .AddClass(GroupDividerClass));
-
- var sweep = CreateHeaderSweep(fixAll, GroupSweepClass, GroupHeaderHoverClass);
- if (isMigration) sweep.AddClass(GroupSweepMigrateClass);
- card.AddChild(sweep);
-
- if (isMigration)
- {
- // Not a guess, so it replaces the Smart Fix row: same confirm + diff preview + Undo flow as a picked fix.
- card.AddChild(BuildGroupActionRow(
- $"Migrate all ({group.Entries.Count}) → {migrationTarget.Name}",
- $"Every entry resolves to {migrationTarget.FullName} via its declared [MovedFrom] — Unity already " +
- "migrates them in memory when the asset loads. Migrating rewrites the stored type name in the " +
- "files so they match the code; the attribute can be removed once no file stores the old name.",
- info: true,
- () => ApplyGroupFix(group, migrationTarget)));
- }
- else if (TryGetGroupSuggestion(group, constraint, out var suggestion))
- {
- // Reuse the shared label/detail builders so the Smart Fix copy never drifts from the inspector notice.
- card.AddChild(BuildGroupActionRow(
- $"Smart Fix {SerializeReferenceHelpers.GetSuggestionLabel(suggestion)}",
- SerializeReferenceHelpers.GetSuggestionDetail(suggestion),
- info: false,
- () => ApplyGroupFix(group, suggestion.Type)));
- }
-
- foreach (var entry in group.Entries)
- card.AddChild(BuildGroupEntryRow(entry));
-
- return card;
- }
-
- // The accent hairline that scales in under a flat header button while it is hovered — shared idiom with the
- // Welcome sample cards. The sweep is the button's sibling, so USS :hover can't reach it; HoverSweep mirrors
- // the hover onto the card modifier (the header's live parent) the sweep rule listens to, keeping it lit while
- // the header is the nav-focused ring member.
- private VisualElement CreateHeaderSweep(AspidGradientButton header, string sweepClass, string hoverClass)
- {
- var sweep = new VisualElement()
- .AddClass(sweepClass)
- .SetPickingMode(PickingMode.Ignore);
-
- HoverSweep.MirrorHover(header, () => header.parent, hoverClass, () => _ring.IsFocused(header));
-
- return sweep;
- }
-
- // A one-click bulk action (Smart Fix / Migrate all) as a member of the entry-row family: a left-aligned
- // accent verb over the same flat hover fill as the ping rows below it, instead of a filled gradient pill
- // floating over the glass card. Each card keeps one accent: warning amber for a Smart Fix guess on a
- // broken card, info for a pending migration.
- private VisualElement BuildGroupActionRow(string text, string tooltipText, bool info, Action onClick)
- {
- var row = new Label(text).AddClass(GroupActionClass);
- if (info) row.AddClass(GroupActionInfoClass);
- row.tooltip = tooltipText;
- row.RegisterCallback(_ => onClick());
- RegisterNavTarget(row, onClick);
- return row;
- }
-
- private static string BuildGroupCountText(ProjectGroup group)
- {
- var entries = group.Entries.Count;
- var files = group.FileCount;
- var entryText = entries == 1 ? "1 entry" : $"{entries} entries";
- var fileText = files == 1 ? "1 file" : $"{files} files";
- return $"{entryText} · {fileText}";
- }
-
- // The header verb plus a trailing chevron; only the glyph changes when the picker opens (ClosePicker relies
- // on that). A broken group's picker fixes ("Fix all"); on a migration card nothing is broken and the picker
- // is the manual escape hatch beside the one-click Migrate all row, so its verb is "Reassign all".
- private static string BuildFixAllLabel(ProjectGroup group, bool expanded, bool isMigration) =>
- $"{(isMigration ? "Reassign all" : "Fix all")} ({group.Entries.Count}) {(expanded ? FixArrowExpanded : FixArrowCollapsed)}";
-
- // Read-only entry row: clicking jumps to the asset — the bulk Fix above is the only mutation in project mode.
- private VisualElement BuildGroupEntryRow(ProjectEntry entry)
- {
- var row = new VisualElement().AddClass(GroupEntryClass);
-
- var path = MakeSelectable(new Label(entry.AssetPath)
- .AddClass(GroupEntryPathClass));
- path.tooltip = entry.AssetPath;
-
- var rid = MakeSelectable(new Label($"rid {entry.Entry.Rid}")
- .AddClass(GroupEntryRidClass));
-
- row.AddChild(path).AddChild(rid);
- RegisterEntryRowClick(row, entry.AssetPath);
-
- return row;
- }
-
- private void RegisterEntryRowClick(VisualElement row, string assetPath)
- {
- row.RegisterCallback(evt =>
- {
- if (evt.target is TextElement text && text.selection.HasSelection()) return;
- JumpToAsset(assetPath);
- });
-
- RegisterNavTarget(row, () => JumpToAsset(assetPath));
- row.AddManipulator(new ContextualMenuManipulator(evt => PopulateEntryContextMenu(evt, assetPath)));
- }
-
- // Right-click alternatives to the row's default left-click jump. Runs after the selectable labels populate
- // their own items (bubble-up), so the menu is wiped first to drop their Copy entry — Cmd+C on a selection
- // still copies, and the menu stays the same three items wherever the click lands.
- private void PopulateEntryContextMenu(ContextualMenuPopulateEvent evt, string assetPath)
- {
- for (var i = evt.menu.MenuItems().Count - 1; i >= 0; i--)
- evt.menu.RemoveItemAt(i);
-
- evt.menu.AppendAction("Open in Asset References", _ => JumpToAsset(assetPath));
-
- evt.menu.AppendAction(
- "Open in Prefab Mode",
- _ => UnityEditor.SceneManagement.PrefabStageUtility.OpenPrefab(assetPath),
- assetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)
- ? DropdownMenuAction.Status.Normal
- : DropdownMenuAction.Status.Disabled);
-
- evt.menu.AppendAction("Select in Project", _ =>
- {
- var asset = AssetDatabase.LoadMainAssetAtPath(assetPath);
- if (asset is null) return;
-
- Selection.activeObject = asset;
- EditorGUIUtility.PingObject(asset);
- });
- }
-
- // The group's bulk picker, inline below the Fix all button, constrained to the group's intersected field type.
- private void ToggleGroupPicker(ProjectGroup group, Type constraint, AspidGradientButton button, bool isMigration)
- {
- var wasOpen = _openPickerRow == button;
- ClosePicker();
- if (wasOpen) return;
-
- var view = BuildPickerView(constraint, assemblyQualifiedName =>
- {
- // emits an empty name: clear the group to null instead of treating it as a no-op.
- if (string.IsNullOrEmpty(assemblyQualifiedName))
- {
- ClearGroupToNull(group);
- return;
- }
-
- var type = ResolveType(assemblyQualifiedName);
- if (type is not null) ApplyGroupFix(group, type);
- });
-
- OpenPickerBelow(button, view);
- button.Text = BuildFixAllLabel(group, expanded: true, isMigration);
- }
-
- // Rewrites every entry in the group to newType after a mandatory confirmation. Rewrites are batched per file
- // so each affected asset is reimported exactly once.
- private void ApplyGroupFix(ProjectGroup group, Type newType)
- {
- if (newType is null) return;
- ClosePicker();
-
- var entries = FilterWritableEntries(group.Entries, out var skipped);
-
- if (entries.Count == 0)
- {
- EditorUtility.DisplayDialog(
- "Repair Missing References",
- "All references in this group live in open scene(s) or Prefab Mode. Close them and rescan, " +
- "or repair the fields directly in the Inspector.",
- "OK");
- return;
- }
-
- var files = entries.Select(entry => entry.AssetPath).Distinct(StringComparer.Ordinal).Count();
- var skippedNote = skipped > 0
- ? $"\n\n{skipped} reference(s) in open scene(s) or Prefab Mode will be skipped."
- : string.Empty;
-
- // When the group's picker fell back to an unconstrained list because its entries' declared field types
- // disagree, the single chosen type cannot fit every entry — warn that the mismatched ones null on reimport.
- group.ResolveConstraint(out var mixedFieldTypes);
- var mixedNote = mixedFieldTypes
- ? "\n\nField types in this group differ — the chosen type may not fit every entry; incompatible ones " +
- "will become null on reimport."
- : string.Empty;
-
- var managedType = ManagedTypeName.FromType(newType);
-
- // The preview is computed by the same scan the rewrite applies, so it shows exactly what gets written.
- var diff = BuildDiffPreview(entries, managedType);
-
- if (!EditorUtility.DisplayDialog(
- "Repair Missing References",
- $"Rewrite {entries.Count} reference(s) in {files} file(s) to '{newType.FullName}'?\n\n" +
- diff +
- "This edits the asset files directly; an Undo button on the summary can revert it." + skippedNote + mixedNote,
- "Rewrite",
- "Cancel"))
- return;
-
- var rewritten = BatchRewriteEntries(entries, managedType, "Repairing References");
-
- SerializeReferenceRepairSuggestions.ClearCache();
-
- var summaryTitle = rewritten == 1 ? "Rewrote 1 reference" : $"Rewrote {rewritten} references";
- var summaryBody = $"Replaced missing '{group.DisplayName}' with '{newType.FullName}'.";
- if (skipped > 0)
- summaryBody += $" Skipped {skipped} in open scene(s) or Prefab Mode.";
-
- // Undo re-points the entries back to the original (now-missing) stored type. Only the type line moved —
- // the data blocks were never touched on disk — so flipping it back is a faithful revert.
- var originalType = group.StoredType;
- var missingName = group.DisplayName;
- var appliedName = newType.FullName;
- void Undo(VisualElement receipt) => UndoGroupFix(entries, originalType, managedType, missingName, appliedName, receipt);
-
- if (_scanButton is not null) _scanButton.Text = RescanLabel;
- var groups = CollectProjectGroups(out var canceled);
-
- if (groups.Count == 0)
- {
- // The fix cleared the last broken type. Stay in the results region instead of the "Project clean"
- // hero, which would hide the summary HelpBox receipt; the hero is reserved for an explicit Rescan.
- ShowMissingReferencesClean();
- }
- else
- {
- RenderGroups(groups, RequiredViolationsForRender, canceled);
- }
-
- ShowSummary(summaryTitle, summaryBody, Undo);
- }
-
- // Clears every entry in the group to null. Closed assets are nulled in the YAML directly; assets open in
- // Prefab Mode / a loaded scene cannot be rewritten on disk (the open copy would clobber it on save), so those
- // are nulled on the live object and stay in the audit until saved. NOT undoable: the broken payload is discarded.
- private void ClearGroupToNull(ProjectGroup group)
- {
- ClosePicker();
-
- SplitWritableEntries(group.Entries, out var onDisk, out var inMemory);
- if (onDisk.Count == 0 && inMemory.Count == 0) return;
-
- var fileCount = onDisk.Select(entry => entry.AssetPath).Distinct(StringComparer.Ordinal).Count();
- var total = onDisk.Count + inMemory.Count;
-
- var openNote = inMemory.Count > 0
- ? $"\n\n{inMemory.Count} reference(s) are open in Prefab Mode or a scene — those are nulled on the live " +
- "object and saved with the asset (the audit keeps listing them until you save)."
- : string.Empty;
- var diskNote = onDisk.Count > 0
- ? $" {onDisk.Count} on disk in {fileCount} file(s) are edited directly."
- : string.Empty;
-
- if (!EditorUtility.DisplayDialog(
- "Clear Missing References",
- $"Clear {total} reference(s) to null?\n\n" +
- BuildClearPreview(group.Entries) +
- $"This nulls every field holding the broken '{group.DisplayName}' and discards its payload." +
- diskNote + " It cannot be undone." + openNote,
- "Clear",
- "Cancel"))
- return;
-
- var clearedOnDisk = BatchNullEntries(onDisk, "Clearing References");
- var clearedInMemory = ClearOpenEntriesInMemory(inMemory, group.StoredType);
- var cleared = clearedOnDisk + clearedInMemory;
-
- SerializeReferenceRepairSuggestions.ClearCache();
-
- // Nothing actually changed (every edit failed) — skip the receipt rather than claim a cleared count of 0.
- if (cleared == 0)
- {
- if (_scanButton is not null) _scanButton.Text = RescanLabel;
- RenderGroups(CollectProjectGroups(out var rescanCanceled), RequiredViolationsForRender, rescanCanceled);
- return;
- }
-
- var summaryTitle = cleared == 1 ? "Cleared 1 reference" : $"Cleared {cleared} references";
- var summaryBody = $"Set missing '{group.DisplayName}' to null.";
- if (clearedInMemory > 0)
- summaryBody += clearedInMemory == 1
- ? " 1 was nulled in memory — save the asset to persist it (still listed until saved)."
- : $" {clearedInMemory} were nulled in memory — save the assets to persist them (still listed until saved).";
-
- // Unlike Fix all (which only swaps a stored type name, never nulls anything), Clear to null CAN turn a
- // required field that held a broken-but-non-null reference into a genuine unset-required violation — drop
- // the stale cache so the Required violations card doesn't under-report until the user rescans.
- _requiredIsWarm = false;
-
- if (_scanButton is not null) _scanButton.Text = RescanLabel;
- var groups = CollectProjectGroups(out var canceled);
-
- if (groups.Count == 0)
- {
- // Same came-back-clean handling as ApplyGroupFix: keep the receipt visible instead of the hero.
- ShowMissingReferencesClean();
- }
- else
- {
- RenderGroups(groups, RequiredViolationsForRender, canceled);
- }
-
- // No Undo: clearing discards the broken payload (see ClearGroupToNull). The receipt is a plain record.
- ShowSummary(summaryTitle, summaryBody, onUndo: null);
- }
-
- // Splits entries into those safe to rewrite on disk and those open in Prefab Mode / a loaded scene, which
- // must be repaired in memory instead.
- private static void SplitWritableEntries(IReadOnlyList source, out List onDisk, out List inMemory)
- {
- var prefabStagePath = CurrentPrefabStagePath();
- onDisk = new List(source.Count);
- inMemory = new List();
-
- foreach (var entry in source)
- {
- if (IsEntryWritable(entry, prefabStagePath)) onDisk.Add(entry);
- else inMemory.Add(entry);
- }
- }
-
- // Nulls each open entry on its live object (the file rewrite is skipped for open assets). The file is unchanged,
- // so these stay in the audit until the asset is saved. Returns how many were cleared.
- private static int ClearOpenEntriesInMemory(IReadOnlyList entries, ManagedTypeName storedType)
- {
- var cleared = 0;
- foreach (var entry in entries)
- {
- if (SerializeReferenceHelpers.TryClearMissingReferenceInMemory(entry.AssetPath, entry.Entry.Rid, storedType))
- cleared++;
- }
-
- return cleared;
- }
-
- // Nulls every entry to the null managed-reference id and drops its payload, batched per file behind a cancel-free
- // progress bar (StartAssetEditing defers each reimport to one pass at the end). Returns how many were cleared.
- private static int BatchNullEntries(IReadOnlyList entries, string progressTitle)
- {
- var byFile = entries
- .GroupBy(entry => entry.AssetPath, StringComparer.Ordinal)
- .ToArray();
-
- var cleared = 0;
-
- AssetDatabase.StartAssetEditing();
- try
- {
- for (var i = 0; i < byFile.Length; i++)
- {
- var file = byFile[i];
- EditorUtility.DisplayProgressBar(
- progressTitle,
- $"{file.Key} ({i + 1}/{byFile.Length})",
- (float)i / byFile.Length);
-
- var changed = false;
- foreach (var entry in file)
- {
- if (!SerializeReferenceYamlEditor.TryNullReference(file.Key, entry.Entry.FileId, entry.Entry.Rid))
- continue;
-
- cleared++;
- changed = true;
- }
-
- if (changed) AssetDatabase.ImportAsset(file.Key, ImportAssetOptions.ForceUpdate);
- }
- }
- finally
- {
- AssetDatabase.StopAssetEditing();
- EditorUtility.ClearProgressBar();
- }
-
- return cleared;
- }
-
- // Capped file + rid list for the confirmation. No before/after lines — the whole entry is being dropped.
- private static string BuildClearPreview(List entries)
- {
- const int maxShown = 8;
- var builder = new System.Text.StringBuilder();
- builder.AppendLine("Clears:");
-
- var shown = 0;
- foreach (var entry in entries)
- {
- if (shown >= maxShown)
- {
- builder.AppendLine($" …and {entries.Count - shown} more");
- break;
- }
-
- builder.AppendLine($" {System.IO.Path.GetFileName(entry.AssetPath)} (rid {entry.Entry.Rid})");
- shown++;
- }
-
- builder.AppendLine();
- return builder.ToString();
- }
-
- // A scene or prefab loaded in the editor would race a file rewrite — the in-memory copy wins on the next save
- // and silently clobbers the on-disk edit. Returns the entries safe to write; reports how many were held back.
- private static List FilterWritableEntries(IReadOnlyList source, out int skipped)
- {
- var prefabStagePath = CurrentPrefabStagePath();
- var writable = new List(source.Count);
- skipped = 0;
-
- foreach (var entry in source)
- {
- if (IsEntryWritable(entry, prefabStagePath)) writable.Add(entry);
- else skipped++;
- }
-
- return writable;
- }
-
- private static string CurrentPrefabStagePath() =>
- UnityEditor.SceneManagement.PrefabStageUtility.GetCurrentPrefabStage()?.assetPath;
-
- // See FilterWritableEntries: an open asset's in-memory copy would clobber the file edit on the next save.
- private static bool IsEntryWritable(ProjectEntry entry, string prefabStagePath)
- {
- var openInScene = UnityEngine.SceneManagement.SceneManager.GetSceneByPath(entry.AssetPath).isLoaded;
- var openInPrefabMode = !string.IsNullOrEmpty(prefabStagePath) &&
- string.Equals(prefabStagePath, entry.AssetPath, StringComparison.Ordinal);
-
- return !openInScene && !openInPrefabMode;
- }
-
- // Rewrites every entry's stored type to targetType, batched per file. StartAssetEditing defers each
- // ImportAsset to one pass at the end. Shared by the forward fix and Undo.
- private static int BatchRewriteEntries(IReadOnlyList entries, ManagedTypeName targetType, string progressTitle)
- {
- var byFile = entries
- .GroupBy(entry => entry.AssetPath, StringComparer.Ordinal)
- .ToArray();
-
- var rewritten = 0;
-
- AssetDatabase.StartAssetEditing();
- try
- {
- for (var i = 0; i < byFile.Length; i++)
- {
- var file = byFile[i];
- EditorUtility.DisplayProgressBar(
- progressTitle,
- $"{file.Key} ({i + 1}/{byFile.Length})",
- (float)i / byFile.Length);
-
- var changed = false;
- foreach (var entry in file)
- {
- if (!SerializeReferenceYamlEditor.TryRewriteType(file.Key, entry.Entry.FileId, entry.Entry.Rid, targetType))
- continue;
-
- rewritten++;
- changed = true;
- }
-
- if (changed) AssetDatabase.ImportAsset(file.Key, ImportAssetOptions.ForceUpdate);
- }
- }
- finally
- {
- AssetDatabase.StopAssetEditing();
- EditorUtility.ClearProgressBar();
- }
-
- return rewritten;
- }
-
- // Reverts one bulk fix by re-pointing its entries back to the original (now-missing) stored type. Only this
- // fix's own receipt is dropped — receipts for other still-applied fixes survive, unlike a full Rescan.
- private void UndoGroupFix(IReadOnlyList entries, ManagedTypeName originalType, ManagedTypeName appliedType, string missingName, string appliedName, VisualElement receipt)
- {
- // The asset may have opened in a scene / Prefab Mode since the fix; apply the same guard as the forward fix.
- var writable = FilterWritableEntries(entries, out var skipped);
-
- // Only entries that STILL hold the type this receipt applied may be re-pointed — the group can have been
- // re-broken and fixed to a DIFFERENT type since, and blindly rewriting would destroy that newer fix.
- // "Still holds it" == a rewrite towards the applied type whose old line already equals its new line.
- var revertible = new List(writable.Count);
- var diverged = 0;
- foreach (var entry in writable)
- {
- if (SerializeReferenceYamlEditor.TryComputeRewrite(entry.AssetPath, entry.Entry.FileId, entry.Entry.Rid, appliedType, out var edit) &&
- edit.IsValid && string.Equals(edit.OldLine, edit.NewLine, StringComparison.Ordinal))
- revertible.Add(entry);
- else
- diverged++;
- }
-
- if (revertible.Count == 0)
- {
- EditorUtility.DisplayDialog(
- "Undo Repair",
- diverged > 0
- ? "These references no longer hold the type this fix applied (they were re-pointed or removed " +
- "since), so there is nothing this undo can safely revert."
- : "These references now live in open scene(s) or Prefab Mode. Close them and try the undo again.",
- "OK");
- return;
- }
-
- var files = revertible.Select(entry => entry.AssetPath).Distinct(StringComparer.Ordinal).Count();
- var skippedNote = skipped > 0
- ? $"\n\n{skipped} reference(s) in open scene(s) or Prefab Mode will be skipped."
- : string.Empty;
- var divergedNote = diverged > 0
- ? $"\n\n{diverged} reference(s) no longer hold '{appliedName}' (changed since this fix) and will be left alone."
- : string.Empty;
-
- if (!EditorUtility.DisplayDialog(
- "Undo Repair",
- $"Re-point {revertible.Count} reference(s) in {files} file(s) back to the missing '{missingName}'?\n\n" +
- $"This restores the broken state you had before replacing it with '{appliedName}', and edits the " +
- "asset files directly." + skippedNote + divergedNote,
- "Undo",
- "Cancel"))
- return;
-
- var reverted = BatchRewriteEntries(revertible, originalType, "Undoing Repair");
-
- SerializeReferenceRepairSuggestions.ClearCache();
-
- // Drop only this receipt — the others describe fixes still applied. RenderGroups rebuilds only _list,
- // never _summaries, so the surviving receipts stay put.
- receipt?.RemoveFromHierarchy();
- var groups = CollectProjectGroups(out var canceled);
- RenderGroups(groups, RequiredViolationsForRender, canceled);
-
- // BatchRewriteEntries can come up short if a file changed between the check and the write — report the real count.
- var undoTitle = reverted == 1 ? "Reverted 1 reference" : $"Reverted {reverted} references";
- var undoBody = $"Re-pointed back to the missing '{missingName}'.";
- if (diverged > 0) undoBody += $" Left {diverged} alone (no longer '{appliedName}').";
- if (reverted < revertible.Count) undoBody += $" {revertible.Count - reverted} could not be rewritten.";
- ShowSummary(undoTitle, undoBody, null);
- }
-
- // Old -> new preview of the YAML the bulk fix will rewrite, using the same TryComputeRewrite the rewrite
- // applies, so the preview is exactly what gets written. Capped so the confirmation stays readable.
- private static string BuildDiffPreview(List entries, ManagedTypeName newType)
- {
- const int maxShown = 8;
- var builder = new System.Text.StringBuilder();
- builder.AppendLine("Changes:");
-
- // Compute first, render second: an uncomputable entry must neither vanish silently nor inflate the
- // "…and N more" remainder.
- var edits = new List<(ProjectEntry entry, RewriteEdit edit)>(entries.Count);
- foreach (var entry in entries)
- {
- if (SerializeReferenceYamlEditor.TryComputeRewrite(entry.AssetPath, entry.Entry.FileId, entry.Entry.Rid, newType, out var edit))
- edits.Add((entry, edit));
- }
-
- for (var i = 0; i < edits.Count && i < maxShown; i++)
- {
- var (entry, edit) = edits[i];
- builder.AppendLine($" {System.IO.Path.GetFileName(entry.AssetPath)} (rid {entry.Entry.Rid}):");
- builder.AppendLine($" - {edit.OldLine.Trim()}");
- builder.AppendLine($" + {edit.NewLine.Trim()}");
- }
-
- if (edits.Count > maxShown)
- builder.AppendLine($" …and {edits.Count - maxShown} more");
-
- var uncomputable = entries.Count - edits.Count;
- if (uncomputable > 0)
- builder.AppendLine($" ({uncomputable} entr{(uncomputable == 1 ? "y" : "ies")} could not be previewed)");
-
- builder.AppendLine();
- return builder.ToString();
- }
-
- // Smart Fix: rank the stored type against the constraint-filtered pool, surfaced only above the confidence
- // threshold. Quick-apply bypasses the picker — safe only because Rank enforces the constraint internally,
- // so the suggestion is always assignable.
- private static bool TryGetGroupSuggestion(ProjectGroup group, Type constraint, out SerializeReferenceRepairSuggestions.RepairCandidate suggestion)
- {
- suggestion = default;
-
- var first = group.Entries[0];
- var fieldNames = SerializeReferenceYamlEditor.GetReferenceFieldNames(first.AssetPath, first.Entry.FileId, first.Entry.Rid);
-
- var ranked = SerializeReferenceRepairSuggestions.Rank(group.StoredType, fieldNames, constraint);
- if (ranked.Count == 0) return false;
-
- suggestion = ranked[0];
- return true;
- }
-
- // ---------------------------------------------------------------------------------------------------------
- // Required violations
- // ---------------------------------------------------------------------------------------------------------
-
- // Flat read-only list of every unset [TypeSelector(Required = true)] field, fed by the same headless scanner
- // as the build/CI gate. No bulk fix here — unlike a broken type, an empty required field has nothing sensible
- // to auto-assign, so the row's only affordance is jumping to the offending asset (where the graph's inline
- // Assign Required picker lives).
- private VisualElement BuildRequiredGroupCard(IReadOnlyList violations)
- {
- var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
- .AddClass(GroupClass);
-
- var header = new AspidLabel("Required violations", AspidLabelPreset.Default
- .SetLabelStatus(StatusStyle.Type.Warning)
- .SetLabelSize(AspidLabelSizeStyle.Type.H5)
- .SetLineSize(AspidDividingLineSizeStyle.Type.None))
- .AddClass(GroupHeaderClass)
- .SetPickingMode(PickingMode.Ignore);
-
- var files = violations.Select(violation => violation.AssetPath).Distinct(StringComparer.Ordinal).Count();
- var count = new Label($"{BuildCountText(violations.Count, "entry")} · {(files == 1 ? "1 file" : $"{files} files")}")
- .AddClass(GroupCountClass)
- .SetPickingMode(PickingMode.Ignore);
-
- var info = new VisualElement()
- .AddClass(GroupHeaderRowClass)
- .AddClass(GroupHeaderRowStaticClass)
- .AddChild(header)
- .AddChild(count);
- info.pickingMode = PickingMode.Ignore;
-
- card.AddChild(info);
-
- // Same header divider as the Fix-all cards, keeping every card's header/body split on one line — but no
- // sweep: this header row is static, there is nothing to hover.
- card.AddChild(new AspidDividingLine(AspidDividingLinePreset.Default
- .SetTheme(ThemeStyle.Type.Light)
- .SetSize(AspidDividingLineSizeStyle.Type.Thin))
- .AddClass(GroupDividerClass));
-
- // Per-card memo, keyed by asset path: several violations commonly share one asset (e.g. a prefab with
- // multiple unset required fields), so this keeps LoadAllAssetsAtPath to once per distinct file instead of
- // once per row.
- var componentCache = new Dictionary(StringComparer.Ordinal);
- foreach (var violation in violations)
- card.AddChild(BuildRequiredViolationRow(violation, componentCache));
-
- return card;
- }
-
- // Read-only entry row: asset path on the left, "Component.field" on the right; the whole row jumps to the
- // asset — same cross-link as a broken-reference row (BuildGroupEntryRow).
- private VisualElement BuildRequiredViolationRow(GateViolation violation, Dictionary componentCache)
- {
- var row = new VisualElement().AddClass(GroupEntryClass);
-
- var path = MakeSelectable(new Label(violation.AssetPath)
- .AddClass(GroupEntryPathClass));
- path.tooltip = violation.AssetPath;
-
- var field = MakeSelectable(new Label(BuildRequiredViolationFieldText(violation, componentCache))
- .AddClass(GroupEntryFieldClass));
-
- row.AddChild(path).AddChild(field);
- RegisterEntryRowClick(row, violation.AssetPath);
-
- return row;
- }
-
- // Cross-link shared by every read-only audit row: jump to the asset's full Inspect graph; ping as a
- // fallback when hosted standalone.
- private void JumpToAsset(string assetPath)
- {
- var asset = AssetDatabase.LoadMainAssetAtPath(assetPath);
- if (asset is null) return;
-
- if (OnInspectAsset is not null) OnInspectAsset(asset);
- else EditorGUIUtility.PingObject(asset);
- }
-
- // "Component.field" for the entry row's right column; GateViolation itself carries no owning-object type,
- // only the asset path and file id, so the component name is resolved on demand for display.
- private static string BuildRequiredViolationFieldText(GateViolation violation, Dictionary componentCache)
- {
- var component = ResolveComponentName(violation, componentCache);
- return string.IsNullOrEmpty(component) ? violation.FieldPath : $"{component}.{violation.FieldPath}";
- }
-
- // Best-effort owning-object type name. Saved assets are object-loaded (once per distinct path, memoised in
- // componentCache) and matched by file id — the same lookup
- // SerializeReferenceGateScanner.CollectRequiredViolations uses internally to build each violation, just for
- // display here. Scenes cannot be object-loaded (see SerializeReferenceHelpers.IsScene), so a scene row shows
- // the field path alone rather than guessing a component name.
- private static string ResolveComponentName(GateViolation violation, Dictionary componentCache)
- {
- if (SerializeReferenceHelpers.IsScene(violation.AssetPath)) return string.Empty;
-
- if (!componentCache.TryGetValue(violation.AssetPath, out var assets))
- {
- assets = AssetDatabase.LoadAllAssetsAtPath(violation.AssetPath);
- componentCache[violation.AssetPath] = assets;
- }
-
- foreach (var asset in assets)
- {
- if (asset == null) continue;
- if (AssetDatabase.TryGetGUIDAndLocalFileIdentifier(asset, out _, out long fileId) && fileId == violation.FileId)
- return asset.GetType().Name;
- }
-
- return string.Empty;
- }
-
- // ---------------------------------------------------------------------------------------------------------
- // Shared picker / results plumbing
- // ---------------------------------------------------------------------------------------------------------
-
- private TypeSelectorView BuildPickerView(Type constraint, Action onSelected)
- {
- var baseType = constraint ?? typeof(object);
-
- return new TypeSelectorView(
- filter: new TypeSelectorFilter
- {
- Types = new[] { baseType },
- Predicate = SerializeReferenceHelpers.IsAssignableManagedReference,
- AdditionalTypes = baseType == typeof(object) ? null : GenericTypeResolver.GetAssignableGenericDefinitions(baseType),
- ArgumentFilter = SerializeReferenceHelpers.IsValidGenericArgument,
- },
- currentAqn: null, // the bulk group picker has no current value — nothing (not even ) wears the check
- onSelected: onSelected,
- onDismiss: ClosePicker);
- }
-
- private void OpenPickerBelow(AspidGradientButton anchor, TypeSelectorView view)
- {
- _openPicker = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
- .AddClass(PickerClass)
- .AddChild(view);
-
- _openPickerRow = anchor;
-
- // The header button is a direct child of the group card, so the picker drops right below it inside the
- // card; the ?? fallback keeps a sane target if the button is ever hosted outside a card.
- var card = anchor.parent;
- var container = card ?? _list;
- container.InsertChild(container.IndexOf(anchor) + 1, _openPicker);
-
- // __group--picking + __picker--attached weld the header, selector and entry rows into one active card.
- if (card is not null)
- {
- _openPickerCard = card;
- _openPickerCard.AddClass(GroupPickingClass);
- _openPicker.AddClass(PickerAttachedClass);
- }
-
- view.FocusPicker();
- }
-
- private void ClosePicker()
- {
- _openPicker?.RemoveFromHierarchy();
- // No group reference here, but only the chevron glyph differs between labels — swap it in place.
- if (_openPickerRow is not null)
- _openPickerRow.Text = _openPickerRow.Text.Replace(FixArrowExpanded, FixArrowCollapsed);
- _openPickerCard?.RemoveClass(GroupPickingClass);
-
- _openPicker = null;
- _openPickerRow = null;
- _openPickerCard = null;
-
- // The dismissed picker leaves keyboard focus dangling on its (removed) search field; reclaim it so the
- // arrow-key ring keeps working. Guarded — ClosePicker also runs from render paths before attach.
- if (panel is not null) Focus();
- }
-
- private static Type ResolveType(string assemblyQualifiedName) =>
- string.IsNullOrEmpty(assemblyQualifiedName)
- ? null
- : Type.GetType(assemblyQualifiedName, throwOnError: false);
-
- private void ShowEmptyState(bool success, string title, string message)
- {
- ResetNavTargets();
- _results.AddClass(ResultsHiddenClass);
- _empty.RemoveClass(EmptyHiddenClass);
- _empty.Clear();
- OnCanvasTone?.Invoke(success ? SerializeReferenceCanvasStyle.Success : SerializeReferenceCanvasStyle.Info);
-
- var icon = new VisualElement()
- .AddClass(EmptyIconClass)
- .AddClass(success ? EmptyIconSuccessClass : EmptyIconInfoClass);
-
- var titlePreset = AspidLabelPreset.Default
- .SetLabelTheme(success ? ThemeStyle.Type.Light : ThemeStyle.Type.Lightness)
- .SetLabelSize(AspidLabelSizeStyle.Type.H3)
- .SetLineSize(AspidDividingLineSizeStyle.Type.None);
-
- if (success) titlePreset = titlePreset.SetLabelStatus(StatusStyle.Type.Success);
-
- _empty.AddChild(icon)
- .AddChild(new AspidLabel(title, titlePreset).AddClass(EmptyTitleClass))
- .AddChild(new Label(message).AddClass(EmptyMessageClass));
- }
-
- // Cold-index idle state until the first scan. No results list yet — the project is unscanned, so "clean"
- // cannot be claimed.
- private void ShowIdle() => ShowEmptyState(
- success: false,
- title: "Project not scanned",
- message: "Run Scan Project to map every broken [SerializeReference] type across your assets — then repair each missing type in bulk.");
-
- // The tone is explicit per call site: the missing-references sweep tones Warning, while the came-back-clean
- // receipt tones Success rather than leaving a clean state on an amber backdrop.
- private void ShowResults(string headerText, Color tone)
- {
- _empty.AddClass(EmptyHiddenClass);
- _results.RemoveClass(ResultsHiddenClass);
- _resultsHeader.Text = headerText;
- OnCanvasTone?.Invoke(tone);
- }
-
- // Appends one receipt to the running stack (newest at the bottom) rather than overwriting the previous; only
- // ClearSummaries resets it on the next fresh scan. The Undo button reverts exactly this fix.
- private void ShowSummary(string title, string message, Action onUndo)
- {
- var summary = new AspidHelpBox(AspidHelpBoxPreset.Default.SetMessageType(HelpBoxMessageType.Warning))
- .AddClass(SummaryClass);
- summary.Title = title;
- summary.Message = message;
-
- if (onUndo is not null)
- summary.AddChild(new AspidGradientButton("Undo", _ => onUndo(summary)).AddClass(SummaryUndoClass));
-
- _summaries.AddChild(summary);
- }
-
- private void ClearSummaries() => _summaries?.Clear();
-
- // ---------------------------------------------------------------------------------------------------------
- // Project scan data
- // ---------------------------------------------------------------------------------------------------------
-
- private readonly struct ProjectEntry
- {
- public readonly string AssetPath;
- public readonly MissingReferenceEntry Entry;
-
- public ProjectEntry(string assetPath, MissingReferenceEntry entry)
- {
- AssetPath = assetPath;
- Entry = entry;
- }
- }
-
- // All broken references sharing one stored type across the project. Resolves a single picker constraint by
- // intersecting the entries' declared field types, falling back to typeof(object) when they disagree.
- private sealed class ProjectGroup
- {
- public readonly ManagedTypeName StoredType;
- public readonly List Entries = new();
-
- private readonly HashSet _files = new(StringComparer.Ordinal);
- private readonly Dictionary> _constraintCache = new(StringComparer.Ordinal);
-
- public ProjectGroup(ManagedTypeName storedType) => StoredType = storedType;
-
- public int FileCount => _files.Count;
-
- public string DisplayName => StoredType.DisplayName;
-
- public void Add(string assetPath, MissingReferenceEntry entry)
- {
- Entries.Add(new ProjectEntry(assetPath, entry));
- _files.Add(assetPath);
- }
-
- // Per-file constraint maps are built once and cached, so the intersection costs one scan per distinct asset.
- public Type ResolveConstraint() => ResolveConstraint(out _);
-
- // Reports whether the typeof(object) fallback came from the field types disagreeing (vs. one being
- // unrecoverable) — the bulk-fix confirmation warns on that case.
- public Type ResolveConstraint(out bool mixedFieldTypes)
- {
- mixedFieldTypes = false;
- Type common = null;
-
- foreach (var entry in Entries)
- {
- if (!_constraintCache.TryGetValue(entry.AssetPath, out var map))
- {
- map = SerializeReferenceHelpers.BuildConstraintMap(entry.AssetPath);
- _constraintCache[entry.AssetPath] = map;
- }
-
- // A field type we cannot recover (a reference nested in a missing parent, or an orphaned rid no
- // field points at) leaves the group unconstrained — a tighter guess could hide a valid pick.
- if (!map.TryGetValue((entry.Entry.FileId, entry.Entry.Rid), out var fieldType) || fieldType is null)
- return typeof(object);
-
- if (common is null)
- {
- common = fieldType;
- }
- else if (common != fieldType)
- {
- mixedFieldTypes = true;
- return typeof(object);
- }
- }
-
- return common ?? typeof(object);
- }
- }
- }
-}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceWindow.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceWindow.cs
deleted file mode 100644
index f5a44435..00000000
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceWindow.cs
+++ /dev/null
@@ -1,346 +0,0 @@
-using System;
-using UnityEditor;
-using UnityEngine;
-using UnityEngine.UIElements;
-using Aspid.FastTools.Editors;
-using Aspid.FastTools.UIElements;
-using UnityEditor.ShortcutManagement;
-using Aspid.FastTools.UIElements.Editors.Internal;
-using Object = UnityEngine.Object;
-
-// ReSharper disable once CheckNamespace
-namespace Aspid.FastTools.SerializeReferences.Editors
-{
- ///
- /// The single managed-reference workbench. Two modes share one window: Asset References maps a saved asset's
- /// whole reference graph and repairs entries inline, and Project References sweeps the project for missing
- /// references and bulk-fixes them grouped by broken type. The per-asset repair list of the old Repair window is
- /// subsumed by the richer Inspect graph; the project sweep keeps its grouped bulk-fix flow.
- ///
- internal sealed class SerializeReferenceWindow : EditorWindow
- {
- // Declaration order mirrors the toolbar left-to-right (Home → Asset References → Project References → Settings);
- // the Ctrl+Tab cycle relies on it, stepping through the values numerically with wrap-around.
- private enum Mode
- {
- Welcome,
- Inspect,
- Project,
- Settings,
- }
-
- // Derived, not hardcoded: a fifth tab added to Mode would otherwise compile cleanly while Ctrl+Tab silently
- // wrapped early and never reached it.
- private static readonly int ModeCount = Enum.GetValues(typeof(Mode)).Length;
-
- private const string RootClass = "aspid-fasttools-serialize-reference-window";
- private const string BackgroundClass = RootClass + "__background";
- private const string ToolbarClass = RootClass + "__toolbar";
- private const string ToolbarButtonClass = RootClass + "__toolbar-button";
- private const string ToolbarButtonActiveClass = ToolbarButtonClass + "--active";
- private const string ToolbarButtonSquareClass = ToolbarButtonClass + "--square";
- private const string TabUnderlineClass = RootClass + "__tab-underline";
- private const string TabHintClass = RootClass + "__tab-hint";
- private const string TabIconClass = RootClass + "__tab-icon";
- private const string TabIconHomeClass = TabIconClass + "--home";
- private const string TabIconSettingsClass = TabIconClass + "--settings";
- private const string ContainerClass = RootClass + "__container";
-
- private const string WindowStyleSheetPath = "UI/SerializeReferences/Aspid-FastTools-SerializeReference-Window";
-
- // The Aspid brand mark shown beside the window title; padded variant so it doesn't dominate the tab.
- private const string WindowIconPath = "Icons/aspid_icon_window_tab_green_1022x1011";
-
- // Below this the toolbar tabs and cards degrade into slivers; applied in CreateGUI so every instance
- // gets it — including panes restored from a saved layout, which never pass through Reveal.
- private static readonly Vector2 MinWindowSize = new(480f, 360f);
-
- // ShortcutManager ids for the tab-switch bindings. They surface under this category in Edit > Shortcuts and are
- // user-rebindable; the visible tab badges read the live binding back from these ids (see BindingLabel).
- private const string ShortcutCategory = "Aspid FastTools/Managed References/";
- private const string HomeShortcutId = ShortcutCategory + "Home";
- private const string InspectShortcutId = ShortcutCategory + "Asset References";
- private const string ProjectShortcutId = ShortcutCategory + "Project References";
- private const string SettingsShortcutId = ShortcutCategory + "Settings";
- private const string NextTabShortcutId = ShortcutCategory + "Next Tab";
- private const string PreviousTabShortcutId = ShortcutCategory + "Previous Tab";
-
- private AspidAnimatedDotsBackground _background;
- private VisualElement _container;
- private Button _homeButton;
- private Button _inspectButton;
- private Button _projectButton;
- private Button _settingsButton;
- // Serialized so the active tab and the inspected asset survive a domain reload — EditorWindow persists
- // [SerializeField] state across assembly reloads; a plain field would reset to its initializer.
- [SerializeField] private Mode _mode = Mode.Inspect;
- [SerializeField] private Object _pendingTarget;
-
- // One-shot flag: the breakage-notification deep-link wants the project scanned immediately even from a cold
- // index, whereas a plain Project References tab click is warmth-gated inside the view. Consumed in SwitchMode.
- private bool _forceProjectScan;
-
- ///
- /// Opens the window on the Welcome home tab — the menu entry and the per-version auto-show.
- ///
- [MenuItem("Tools/Aspid 🐍/FastTools/Welcome", priority = 0)]
- public static void OpenWelcome()
- {
- var window = Reveal();
- window.SwitchMode(Mode.Welcome);
- WelcomeWindowStartup.MarkSeen();
- }
-
- // The priority gap (0 → 20 → 40) is wider than Unity's 10-step separator threshold, so the menu renders
- // Welcome / [Asset + Project References] / Settings as three separated groups.
- [MenuItem("Tools/Aspid 🐍/FastTools/Asset References", priority = 20)]
- private static void OpenMenu() => Open(Selection.activeObject);
-
- ///
- /// Opens the window in Inspect mode on (the deep-link for per-asset repair).
- ///
- public static void Open(Object target)
- {
- var window = Reveal();
- window._pendingTarget = target;
- window.SwitchMode(Mode.Inspect);
- }
-
- ///
- /// Opens the window on the Project References tab (no auto-scan — the idle Scan panel shows first).
- ///
- [MenuItem("Tools/Aspid 🐍/FastTools/Project References", priority = 21)]
- private static void OpenProject() => Reveal().SwitchMode(Mode.Project);
-
- ///
- /// Opens the window straight into a project audit (the breakage-notification deep-link).
- ///
- public static void OpenProjectScan()
- {
- var window = Reveal();
- window._forceProjectScan = true;
- window.SwitchMode(Mode.Project);
- }
-
- ///
- /// Opens the window on the Settings tab. Also the deep-link target of the type selector's footer gear.
- ///
- [MenuItem("Tools/Aspid 🐍/FastTools/Settings", priority = 40)]
- public static void OpenSettings() => Reveal().SwitchMode(Mode.Settings);
-
- private static SerializeReferenceWindow Reveal()
- {
- // Title and minSize are owned by CreateGUI, so every instance gets them — including panes restored
- // from a saved layout or created by scripts, which never pass through here.
- var window = GetWindow();
- window.Show();
- return window;
- }
-
- private void CreateGUI()
- {
- minSize = MinWindowSize;
- titleContent = new GUIContent("Aspid FastTools", Resources.Load(WindowIconPath));
-
- var root = rootVisualElement;
- root.AddAspidThemeStyleSheets()
- .AddStyleSheetsFromResource(WindowStyleSheetPath)
- .AddClass(RootClass);
-
- // One dotted canvas, owned by the window, fills it behind everything; its tint follows the active view's
- // state via the SetCanvasTone callback handed to each view.
- _background = new AspidAnimatedDotsBackground()
- .AddClass(BackgroundClass)
- .SetPickingMode(PickingMode.Ignore);
-
- _homeButton = SquareTabButton(Mode.Welcome, TabIconHomeClass, BindingLabel(HomeShortcutId, 1));
- _inspectButton = ModeButton("Asset References", Mode.Inspect, BindingLabel(InspectShortcutId, 2));
- _projectButton = ModeButton("Project References", Mode.Project, BindingLabel(ProjectShortcutId, 3));
- _settingsButton = SquareTabButton(Mode.Settings, TabIconSettingsClass, BindingLabel(SettingsShortcutId, 0));
-
- var toolbar = new VisualElement().AddClass(ToolbarClass);
- toolbar.AddChild(_homeButton)
- .AddChild(_inspectButton)
- .AddChild(_projectButton)
- .AddChild(_settingsButton);
-
- _container = new VisualElement().AddClass(ContainerClass);
- _container.style.flexGrow = 1;
-
- // The footer is owned by the window, not any single tab, so it stays pinned to the bottom across every
- // mode; _container (flex-grow:1) pushes it down.
- root.AddChild(_background)
- .AddChild(toolbar)
- .AddChild(_container)
- .AddChild(new AspidWindowFooter());
-
- SwitchMode(_mode);
- }
-
- private Button ModeButton(string label, Mode mode, string hint)
- {
- var button = new Button(() => SwitchMode(mode)) { text = label, tooltip = hint };
- button.AddClass(ToolbarButtonClass);
-
- // Shortcut badge, absolutely positioned so it floats over the button without disturbing the centred label.
- button.AddChild(new Label(hint)
- .AddClass(TabHintClass)
- .SetPickingMode(PickingMode.Ignore));
-
- // The active underline is a child bar, not a border-bottom — flipping a child's background-color via the
- // parent's --active class repaints reliably (a border-color flip only showed up after a window resize).
- button.AddChild(new VisualElement()
- .AddClass(TabUnderlineClass)
- .SetPickingMode(PickingMode.Ignore));
-
- return button;
- }
-
- // The edge tabs (home / settings) are square and icon-only: the USS --square modifier overrides the flex
- // sizing, the inner __tab-icon modifier supplies the glyph. Same underline bar as the mode tabs.
- private Button SquareTabButton(Mode mode, string iconModifierClass, string hint)
- {
- var button = new Button(() => SwitchMode(mode)) { tooltip = hint };
- button.AddClass(ToolbarButtonClass).AddClass(ToolbarButtonSquareClass);
-
- button.AddChild(new VisualElement()
- .AddClass(TabIconClass)
- .AddClass(iconModifierClass)
- .SetPickingMode(PickingMode.Ignore));
-
- button.AddChild(new VisualElement()
- .AddClass(TabUnderlineClass)
- .SetPickingMode(PickingMode.Ignore));
-
- return button;
- }
-
- // Registered against this window as the context: a context shortcut fires whenever the window is focused,
- // unlike a panel KeyDownEvent, which goes silent when focus sits on empty chrome. Alt+digit because Unity
- // reserves every primary-modifier digit combo globally; user-rebindable in Edit > Shortcuts.
- [Shortcut(HomeShortcutId, typeof(SerializeReferenceWindow), KeyCode.Alpha1, ShortcutModifiers.Alt)]
- private static void OnHomeShortcut(ShortcutArguments args) => SwitchFrom(args, Mode.Welcome);
-
- [Shortcut(InspectShortcutId, typeof(SerializeReferenceWindow), KeyCode.Alpha2, ShortcutModifiers.Alt)]
- private static void OnInspectShortcut(ShortcutArguments args) => SwitchFrom(args, Mode.Inspect);
-
- [Shortcut(ProjectShortcutId, typeof(SerializeReferenceWindow), KeyCode.Alpha3, ShortcutModifiers.Alt)]
- private static void OnProjectShortcut(ShortcutArguments args) => SwitchFrom(args, Mode.Project);
-
- [Shortcut(SettingsShortcutId, typeof(SerializeReferenceWindow), KeyCode.Alpha0, ShortcutModifiers.Alt)]
- private static void OnSettingsShortcut(ShortcutArguments args) => SwitchFrom(args, Mode.Settings);
-
- // Browser-style cyclic tab switching. ShortcutModifiers.Control is deliberate on BOTH platforms — Action would
- // map to ⌘ on macOS, and Cmd+Tab is reserved by the OS for the application switcher. If the ShortcutManager
- // ever refuses KeyCode.Tab, the fallback is a TrickleDown KeyDownEvent on rootVisualElement (KeyDown caveat above).
- [Shortcut(NextTabShortcutId, typeof(SerializeReferenceWindow), KeyCode.Tab, ShortcutModifiers.Control)]
- private static void OnNextTabShortcut(ShortcutArguments args) => CycleFrom(args, +1);
-
- [Shortcut(PreviousTabShortcutId, typeof(SerializeReferenceWindow), KeyCode.Tab, ShortcutModifiers.Control | ShortcutModifiers.Shift)]
- private static void OnPreviousTabShortcut(ShortcutArguments args) => CycleFrom(args, -1);
-
- private static void SwitchFrom(ShortcutArguments args, Mode mode)
- {
- if (args.context is SerializeReferenceWindow window)
- window.SwitchMode(mode);
- }
-
- private static void CycleFrom(ShortcutArguments args, int step)
- {
- if (args.context is not SerializeReferenceWindow window) return;
-
- var next = (Mode)(((int)window._mode + step + ModeCount) % ModeCount);
- window.SwitchMode(next);
- }
-
- // The tab's badge / tooltip: the live binding read from the ShortcutManager, so it tracks user rebinds and
- // renders the real per-platform glyph. Falls back to the static default when the id isn't registered yet or
- // its binding has been cleared.
- private static string BindingLabel(string shortcutId, int number)
- {
- try
- {
- var binding = ShortcutManager.instance.GetShortcutBinding(shortcutId).ToString();
- if (!string.IsNullOrEmpty(binding)) return binding;
- }
- catch (System.Exception)
- {
- // ShortcutManager not ready / unknown id — fall through to the static default below.
- }
-
- return ShortcutHint(number);
- }
-
- // BindingLabel's fallback, mirroring the [Shortcut] defaults: the ⌥ glyph on macOS, spelled-out Alt+ elsewhere.
- private static string ShortcutHint(int number) =>
- (Application.platform == RuntimePlatform.OSXEditor ? "⌥" : "Alt+") + number;
-
- private void SwitchMode(Mode mode)
- {
- _mode = mode;
- if (_container is null) return; // Open() ran before CreateGUI; CreateGUI re-invokes SwitchMode(_mode).
-
- _container.Clear();
-
- if (mode == Mode.Welcome)
- {
- // Welcome carries no single status; restore the default signal gradient a prior view's tone flattened.
- _background?.SetSignalGradient();
- _container.AddChild(new WelcomeView());
- }
- else if (mode == Mode.Inspect)
- {
- // Track the in-view pick back onto _pendingTarget so a tab switch rebuilds the view on the asset the user
- // actually has open, not the one Inspect first opened on.
- _container.AddChild(new SerializeReferenceGraphView(_pendingTarget, SetCanvasTone, target => _pendingTarget = target));
- }
- else if (mode == Mode.Settings)
- {
- // Settings carries no status either; the calm idle tone keeps the canvas neutral here.
- SetCanvasTone(SerializeReferenceCanvasStyle.Info);
- _container.AddChild(new SettingsView());
- }
- else
- {
- var project = new SerializeReferenceProjectView
- {
- OnInspectAsset = InspectAsset,
- OnCanvasTone = SetCanvasTone,
- };
- _container.AddChild(project);
-
- // A plain tab switch never auto-scans (no scan freeze on large projects); only the
- // breakage-notification deep-link forces the scan.
- if (_forceProjectScan)
- {
- _forceProjectScan = false;
- project.ScanProject();
- }
- else
- {
- project.Initialize();
- }
- }
-
- UpdateToolbar();
- }
-
- // The active view reports its state-tone here; the window owns the shared dotted canvas and applies it.
- private void SetCanvasTone(Color tone) => _background?.SetTone(tone);
-
- // Cross-link: jumping from a project-audit result to that asset's full graph.
- private void InspectAsset(Object target)
- {
- _pendingTarget = target;
- SwitchMode(Mode.Inspect);
- }
-
- private void UpdateToolbar()
- {
- _homeButton?.EnableInClassList(ToolbarButtonActiveClass, _mode == Mode.Welcome);
- _inspectButton?.EnableInClassList(ToolbarButtonActiveClass, _mode == Mode.Inspect);
- _projectButton?.EnableInClassList(ToolbarButtonActiveClass, _mode == Mode.Project);
- _settingsButton?.EnableInClassList(ToolbarButtonActiveClass, _mode == Mode.Settings);
- }
- }
-}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Settings/AspidSettingsUI.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Settings/AspidSettingsUI.cs
index 009f0b41..15bd4260 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Settings/AspidSettingsUI.cs
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Settings/AspidSettingsUI.cs
@@ -154,10 +154,11 @@ private static void BuildProviderHost(VisualElement root, Action
.AddStyleSheetsFromResource(StyleSheetPath)
.AddClass(CanvasClass);
+ // A settings page carries no status of its own; the calm idle wash keeps the canvas neutral here.
var canvas = new AspidAnimatedDotsBackground()
+ .SetStatus(StatusStyle.Type.Info)
.AddClass(CanvasBackgroundClass)
.SetPickingMode(PickingMode.Ignore);
- canvas.SetTone(SerializeReferenceCanvasStyle.Info);
var surface = new VisualElement().AddClass(RootClass);
fill(surface);
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/TypeSelectorView.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/TypeSelectorView.cs
index 65a1a6a0..06afb16b 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/TypeSelectorView.cs
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Types/Selectors/TypeSelectorView.cs
@@ -213,7 +213,7 @@ private void BuildUI()
_settingsButton.clicked += () =>
{
_onDismiss?.Invoke();
- SerializeReferences.Editors.SerializeReferenceWindow.OpenSettings();
+ SerializeReferences.Editors.TabWindow.OpenSettings();
};
_breadcrumbBar.RegisterCallback(_ => OpenSearch());
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/CLAUDE.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/CLAUDE.md
index 99b213b3..0bbae125 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/CLAUDE.md
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/CLAUDE.md
@@ -26,7 +26,7 @@ Components/AspidGradientButton/
|---|---|
| `Styles/AspidStyles.cs` | default stylesheet + shared USS constants |
| `Styles/StatusStyle.cs`, `ThemeStyle.cs`, `InlineStyle` | shared style helpers |
-| `NavRing.cs`, `HoverSweep.cs` | keyboard nav ring and hover sweep, shared across window tabs |
+| `NavRing.cs` | keyboard nav ring, shared across window tabs |
| `DoubleClickTracker.cs` | double-click detection |
`ICustomStyleExtensions` is **not** here — it ships in runtime, at
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Components/AspidAnimatedDotsBackground/AspidAnimatedDotsBackground.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Components/AspidAnimatedDotsBackground/AspidAnimatedDotsBackground.cs
index 170a739f..5a92e729 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Components/AspidAnimatedDotsBackground/AspidAnimatedDotsBackground.cs
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Components/AspidAnimatedDotsBackground/AspidAnimatedDotsBackground.cs
@@ -19,11 +19,28 @@ internal sealed partial class AspidAnimatedDotsBackground : VisualElement
private readonly Vector2[] _blobRadii = new Vector2[BlobCount];
private readonly Vector2[] _blobCenters = new Vector2[BlobCount];
+ private readonly StatusStyle _status;
private readonly AspidAnimatedDotsBackgroundColorsStyle _colors;
private readonly AspidAnimatedDotsBackgroundSizeStyle _size;
private IVisualElementScheduledItem _animation;
+ ///
+ /// Gets or sets the status wash: one dim, desaturated hue across all three blobs, so the canvas reads as a
+ /// calm state backdrop. restores the default three-tone signal gradient.
+ ///
+ ///
+ /// Drives the blob colors through USS (the status class swaps the --aspid-fasttools-colors-dot_blob-color_*
+ /// properties), so it stays live across theme changes — unlike – , which
+ /// pin their blob inline and opt it out of USS resolution for good.
+ ///
+ [UxmlAttribute]
+ public StatusStyle.Type Status
+ {
+ get => _status.Value;
+ set => _status.SetValue(value);
+ }
+
///
/// Gets or sets the color of the first blob.
///
@@ -100,6 +117,8 @@ public AspidAnimatedDotsBackground(AspidAnimatedDotsBackgroundPreset preset)
this.AddStyleSheetsFromResource(StyleSheetPath);
generateVisualContent += OnGenerateVisualContent;
+ _status = new StatusStyle(this, preset.Status);
+
_colors = new AspidAnimatedDotsBackgroundColorsStyle(
this, preset.Color1, preset.Color2, preset.Color3, MarkDirtyRepaint);
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Components/AspidAnimatedDotsBackground/AspidAnimatedDotsBackgroundExtensions.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Components/AspidAnimatedDotsBackground/AspidAnimatedDotsBackgroundExtensions.cs
index 30d12076..b5dcee51 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Components/AspidAnimatedDotsBackground/AspidAnimatedDotsBackgroundExtensions.cs
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Components/AspidAnimatedDotsBackground/AspidAnimatedDotsBackgroundExtensions.cs
@@ -8,6 +8,15 @@ namespace Aspid.FastTools.UIElements.Editors.Internal
///
internal static class AspidAnimatedDotsBackgroundExtensions
{
+ ///
+ /// Sets and returns the element for chaining.
+ ///
+ public static AspidAnimatedDotsBackground SetStatus(this AspidAnimatedDotsBackground element, StatusStyle.Type value)
+ {
+ element.Status = value;
+ return element;
+ }
+
///
/// Sets and returns the element for chaining.
///
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Components/AspidAnimatedDotsBackground/AspidAnimatedDotsBackgroundPreset.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Components/AspidAnimatedDotsBackground/AspidAnimatedDotsBackgroundPreset.cs
index 8a32bdef..da15cf2a 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Components/AspidAnimatedDotsBackground/AspidAnimatedDotsBackgroundPreset.cs
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Components/AspidAnimatedDotsBackground/AspidAnimatedDotsBackgroundPreset.cs
@@ -15,7 +15,13 @@ internal struct AspidAnimatedDotsBackgroundPreset
public static AspidAnimatedDotsBackgroundPreset Default => new AspidAnimatedDotsBackgroundPreset()
.SetDotSpacing(18)
.SetDotRadius(1.55f)
- .SetScaleReferenceSize(420);
+ .SetScaleReferenceSize(420)
+ .SetStatus(StatusStyle.Type.None);
+
+ ///
+ /// Status wash across all three blobs. leaves the default signal gradient.
+ ///
+ public StatusStyle.Type Status;
///
/// Color of the first blob. Falls back to USS when default.
@@ -50,6 +56,15 @@ internal struct AspidAnimatedDotsBackgroundPreset
///
public float ScaleReferenceSize;
+ ///
+ /// Sets and returns the modified preset.
+ ///
+ public AspidAnimatedDotsBackgroundPreset SetStatus(StatusStyle.Type value)
+ {
+ Status = value;
+ return this;
+ }
+
///
/// Sets and returns the modified preset.
///
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/HoverSweep.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/HoverSweep.cs
deleted file mode 100644
index 5dcd081e..00000000
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/HoverSweep.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-using System;
-using UnityEngine.UIElements;
-
-// ReSharper disable once CheckNamespace
-namespace Aspid.FastTools.UIElements.Editors.Internal
-{
- ///
- /// Wires the window cards' hover-sweep idiom: a flat header button whose accent underline sweep is a sibling (so
- /// USS :hover can't reach it) mirrors its hover onto an ancestor card modifier class the sweep rule
- /// listens to. Composed with — while the button is the nav-focused ring member the sweep
- /// stays lit after the mouse leaves, matching the programmatic hover the ring paints — so keyboard focus and mouse
- /// hover render identically. Shared by the Welcome sample cards and both References audit tabs so the composition
- /// (which ancestor carries the modifier, and the keep-lit-while-focused guard) can't drift between them.
- ///
- internal static class HoverSweep
- {
- ///
- /// Mirrors 's mouse hover onto the modifier on the card
- /// resolved by — resolved at event time, so it works whether the card is a
- /// captured local or the button's live parent. The modifier is kept lit while
- /// reports the button as the focus-ring member, so a mouse pass over a keyboard-focused card never
- /// half-extinguishes its sweep.
- ///
- public static void MirrorHover(
- VisualElement button,
- Func resolveCard,
- string hoverClass,
- Func isNavFocused)
- {
- button.RegisterCallback(_ => resolveCard()?.AddToClassList(hoverClass));
- button.RegisterCallback(_ =>
- {
- if (isNavFocused()) return;
- resolveCard()?.RemoveFromClassList(hoverClass);
- });
- }
- }
-}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/HoverSweep.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/HoverSweep.cs.meta
deleted file mode 100644
index 3ea813e5..00000000
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/HoverSweep.cs.meta
+++ /dev/null
@@ -1,2 +0,0 @@
-fileFormatVersion: 2
-guid: 7679e58536d5e4178af27c895d1ae38f
\ No newline at end of file
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/NavRing.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/NavRing.cs
index 828f7ce4..5dc1d29d 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/NavRing.cs
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/NavRing.cs
@@ -10,8 +10,8 @@ namespace Aspid.FastTools.UIElements.Editors.Internal
/// The window's shared keyboard focus ring: one flat list of actionable elements walked with ↑/↓, activated with
/// Enter and dropped with Escape; sliders take ←/→ (Adjust) and removable rows take Delete/Backspace (Remove). The
/// window views (Welcome, Asset / Project References, Settings) all drive from this one ring so their keyboard
- /// behaviour stays identical — each supplies only how a focused member is painted, how to scroll it into view,
- /// and (optionally) when the ring is suspended because its own picker owns the keyboard.
+ /// behaviour stays identical — each supplies only how to scroll a member into view, which class marks a focused
+ /// plain row, and (optionally) when the ring is suspended because its own picker owns the keyboard.
///
///
/// The ring never moves real keyboard focus onto its members — the host element keeps focus and the ring only
@@ -23,30 +23,44 @@ internal sealed class NavRing
///
/// One ring member: the element plus what Enter ( ), ←/→ ( , sliders)
/// and Delete/Backspace ( , removable rows) do to it. /
- /// are for members that don't take them.
+ /// are for members that don't take them. /
+ /// carry the card-level sweep modifier of a header member (see ).
///
- internal readonly struct Target
+ private readonly struct Target
{
public readonly VisualElement Element;
public readonly Action Activate;
public readonly Action Adjust;
public readonly Action Remove;
-
- public Target(VisualElement element, Action activate, Action adjust, Action remove)
+ public readonly VisualElement HoverCard;
+ public readonly string HoverClass;
+
+ public Target(
+ VisualElement element,
+ Action activate,
+ Action adjust,
+ Action remove,
+ VisualElement hoverCard,
+ string hoverClass)
{
Element = element;
Activate = activate;
Adjust = adjust;
Remove = remove;
+ HoverCard = hoverCard;
+ HoverClass = hoverClass;
}
}
private readonly List _targets = new();
private int _index = -1;
+ // Set by Clear(keepFocusedElement: true) and consumed by the member's own re-registration — see Add.
+ private VisualElement _restore;
+
private readonly VisualElement _host;
private readonly string _navTargetClass;
- private readonly Action _paint;
+ private readonly string _focusedClass;
private readonly Action _scrollTo;
private readonly Func _isSuspended;
@@ -56,19 +70,19 @@ public Target(VisualElement element, Action activate, Action adjust, Action
///
/// The element that holds keyboard focus and receives the ring's key events.
/// USS class applied to every registered member (the view's __nav-target ).
- /// Lights ( ) or clears ( ) a member's focus treatment.
+ /// USS class marking a focused plain row (the view's __nav-target--focused ); may be for a ring made only of gradient buttons, which paint their focus in code.
/// Scrolls a member into view when focus lands on it; may be .
/// When it returns the ring ignores all keys (e.g. an open picker owns them); may be .
public NavRing(
VisualElement host,
string navTargetClass,
- Action paint,
+ string focusedClass = null,
Action scrollTo = null,
Func isSuspended = null)
{
_host = host;
_navTargetClass = navTargetClass;
- _paint = paint;
+ _focusedClass = focusedClass;
_scrollTo = scrollTo;
_isSuspended = isSuspended;
@@ -77,57 +91,114 @@ public NavRing(
host.RegisterCallback(_ => host.schedule.Execute(() => host.Focus()));
}
- /// The highlighted member's index, or -1 when nothing is highlighted.
- public int Index => _index;
+ ///
+ /// Appends a member in visual order. handles ←/→ (sliders);
+ /// handles Delete/Backspace (removable rows).
+ ///
+ public void Register(VisualElement element, Action activate, Action adjust = null, Action remove = null) =>
+ Add(new Target(element, activate, adjust, remove, hoverCard: null, hoverClass: null));
- /// The number of registered members.
- public int Count => _targets.Count;
+ ///
+ /// Appends a card's flat header button, wiring both halves of the card's hover-sweep idiom in one place: the
+ /// accent sweep is the header's sibling (so USS :hover can't reach it) and instead rides
+ /// on — the ring lights that modifier while the header is
+ /// the highlighted member, and mirrors the mouse hover onto it otherwise, so keyboard focus and mouse hover
+ /// render identically. Shared by the Welcome sample cards and both References audit tabs.
+ ///
+ ///
+ /// Registers hover callbacks on , so it takes a freshly built element — a member that
+ /// outlives a rebuild and re-registers (the pinned Scan action) belongs on .
+ ///
+ public void RegisterHeader(VisualElement header, VisualElement card, string hoverClass, Action activate)
+ {
+ Add(new Target(header, activate, adjust: null, remove: null, card, hoverClass));
- /// Whether is the currently highlighted member.
- public bool IsFocused(VisualElement element) =>
- _index >= 0 && _index < _targets.Count && _targets[_index].Element == element;
+ header.RegisterCallback(_ => card.EnableInClassList(hoverClass, true));
+ header.RegisterCallback(_ =>
+ {
+ // The highlighted card keeps its sweep lit, so a mouse pass over it never half-extinguishes it.
+ if (!IsFocused(header)) card.EnableInClassList(hoverClass, false);
+ });
+ }
///
- /// Appends a member in visual order. handles ←/→ (sliders);
- /// handles Delete/Backspace (removable rows). EnableInClassList (not Add), so a member re-registered on a ring
- /// rebuild never stacks the class twice.
+ /// Rebuilds the ring in one pass: clears it, runs , then puts the highlight back on
+ /// the same slot — clamped to the rebuilt member count and unscrolled, since a scroll on a rebuild is jarring.
+ /// For rings whose members are all recreated, where the highlight can only be restored positionally; a ring
+ /// with members that outlive the rebuild uses instead.
///
- public void Register(VisualElement element, Action activate, Action adjust = null, Action remove = null)
+ public void Rebuild(Action register)
{
- element.EnableInClassList(_navTargetClass, true);
- _targets.Add(new Target(element, activate, adjust, remove));
+ var slot = _index;
+
+ Clear();
+ register();
+
+ if (slot >= 0 && _targets.Count > 0)
+ Focus(Mathf.Min(slot, _targets.Count - 1), scrollTo: false);
}
- /// Clears the highlight and drops every member — a full ring rebuild follows with fresh calls.
- public void Clear()
+ ///
+ /// Clears the highlight and drops every member — a full ring rebuild follows with fresh
+ /// calls. With the highlighted element is remembered and takes its
+ /// highlight back the moment it re-registers, so a member that outlives the rebuild (the pinned Scan action)
+ /// keeps its highlight while one sitting on a discarded member simply drops.
+ ///
+ public void Clear(bool keepFocusedElement = false)
{
+ _restore = keepFocusedElement && _index >= 0 && _index < _targets.Count
+ ? _targets[_index].Element
+ : null;
+
ClearFocus();
_targets.Clear();
}
- /// Drops the highlight without touching the member list.
- public void ClearFocus()
+ // EnableInClassList (not Add), so a member re-registered on a ring rebuild never stacks the class twice.
+ private void Add(in Target target)
+ {
+ target.Element.EnableInClassList(_navTargetClass, true);
+ _targets.Add(target);
+
+ if (_restore != target.Element) return;
+
+ _restore = null;
+ Focus(_targets.Count - 1, scrollTo: false);
+ }
+
+ // Gradient buttons paint their hover in code (accent overlay + tinted labels); the focused class's flat fill
+ // would just show through their fading gradient as a gray pill, so they take ONLY the programmatic hover and
+ // the plain rows take ONLY the class. A header member also lights its card's sweep modifier — the same class
+ // its mouse hover mirrors (see RegisterHeader).
+ private void Paint(in Target target, bool on)
+ {
+ if (target.Element is AspidGradientButton button) button.Highlighted = on;
+ else if (_focusedClass is not null) target.Element.EnableInClassList(_focusedClass, on);
+
+ target.HoverCard?.EnableInClassList(target.HoverClass, on);
+ }
+
+ private bool IsFocused(VisualElement element) =>
+ _index >= 0 && _index < _targets.Count && _targets[_index].Element == element;
+
+ private void ClearFocus()
{
if (_index >= 0 && _index < _targets.Count)
- _paint(_targets[_index].Element, false);
+ Paint(_targets[_index], false);
_index = -1;
}
- ///
- /// Highlights the member at , scrolling it into view unless
- /// is — used when restoring a highlight after a rebuild, where a scroll would be jarring.
- ///
- public void Focus(int index, bool scrollTo = true)
+ private void Focus(int index, bool scrollTo = true)
{
if (_index == index) return;
ClearFocus();
_index = index;
- var element = _targets[index].Element;
- _paint(element, true);
- if (scrollTo) _scrollTo?.Invoke(element);
+ var target = _targets[index];
+ Paint(target, true);
+ if (scrollTo) _scrollTo?.Invoke(target.Element);
}
private void OnKeyDown(KeyDownEvent evt)
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows.meta
similarity index 100%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows.meta
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References.meta
new file mode 100644
index 00000000..773b44cf
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 5fd3b3c3c34a9458db9337174ba508b4
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset.meta
new file mode 100644
index 00000000..dfb609ae
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 9f1f4c4b89704fb895d1a6de78f72e76
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphAnalysis.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphAnalysis.cs
new file mode 100644
index 00000000..95b605c3
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphAnalysis.cs
@@ -0,0 +1,159 @@
+using System;
+using System.Linq;
+using System.Collections.Generic;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ ///
+ /// Reads a scanned reference graph without drawing it: what is broken, what is merely a pending
+ /// [MovedFrom] rename, which slots sit empty and what a broken node's best repair guess is.
+ ///
+ ///
+ /// The migration checks need the declared field type behind a rid, so they take the caller's
+ /// rather than building a constraint map per call — one asset scan
+ /// is shared by every question asked about that asset in one render pass.
+ ///
+ internal static class SerializeReferenceGraphAnalysis
+ {
+ /// Joins a parent field path with a child edge label, tolerating either side being empty.
+ public static string CombinePath(string parent, string child)
+ {
+ if (string.IsNullOrEmpty(child)) return parent;
+ return string.IsNullOrEmpty(parent) ? child : $"{parent}.{child}";
+ }
+
+ ///
+ /// Every empty managed-reference slot's normalized field path across every document, root and nested edge.
+ ///
+ ///
+ /// Mirrors the card-building walk minus the cards. It is the lookup set an empty slot's required badge is
+ /// checked against, and the way the graph tells "already badged on a card" apart from "no card exists for this
+ /// field at all" — a required string / SerializableType field has no rid and so no node.
+ ///
+ public static HashSet<(long fileId, string path)> CollectEmptySlotPaths(List documents)
+ {
+ var paths = new HashSet<(long, string)>();
+
+ foreach (var document in documents)
+ {
+ foreach (var root in document.Roots)
+ {
+ if (root.IsEmpty)
+ paths.Add((document.FileId, SerializeReferenceGraphEditor.ToSerializedPropertyPath(root.Label)));
+ else
+ WalkForEmptySlots(document, root.Rid, root.Label, new HashSet(), paths);
+ }
+ }
+
+ return paths;
+ }
+
+ /// How many slots in this document are unassigned. Used only for the overview hint; empty slots are not "issues".
+ public static int CountEmptySlots(ReferenceGraphDocument document)
+ {
+ var count = document.Roots.Count(root => root.IsEmpty);
+
+ foreach (var pair in document.Edges)
+ {
+ count += pair.Value.Count(edge => edge.IsEmpty);
+ }
+
+ return count;
+ }
+
+ ///
+ /// Splits a document's unresolved nodes into genuinely broken ones and pending [MovedFrom] migrations.
+ ///
+ ///
+ /// An orphaned rid always counts as broken — nothing loads an orphan, so in-memory migration does not apply.
+ /// It is also excluded from the migration tally because the orphan counters already own it; counting it here
+ /// too would double it in the overview headline and hints.
+ ///
+ public static (int broken, int migrations) CountUnresolved(string assetPath, ReferenceGraphDocument document,
+ SerializeReferenceConstraintCache constraints)
+ {
+ var broken = 0;
+ var migrations = 0;
+
+ foreach (var node in document.Nodes)
+ {
+ if (node.Resolves || node.StoredType.IsEmpty) continue;
+ if (document.Orphans.Contains(node.Rid)) continue;
+
+ if (IsPendingMigration(assetPath, document.FileId, node.Rid, node.StoredType, constraints, out _))
+ migrations++;
+ else
+ broken++;
+ }
+
+ return (broken, migrations);
+ }
+
+ /// The missing-predicate the amber tint uses; also drives the missing-first root ordering.
+ public static bool RootIsMissing(ReferenceGraphDocument document, long rid)
+ {
+ var node = document.FindNode(rid);
+ return node is { Resolves: false, StoredType: { IsEmpty: false } };
+ }
+
+ ///
+ /// Whether a missing node's stored type is claimed by exactly one [MovedFrom] target that fits the
+ /// field's declared type — Unity already migrates it in memory, so only the file is stale.
+ ///
+ /// An unrecoverable constraint lets the migration through.
+ public static bool IsPendingMigration(string assetPath, long fileId, long rid, ManagedTypeName storedType,
+ SerializeReferenceConstraintCache constraints, out Type target)
+ {
+ if (!SerializeReferenceMovedFromResolver.TryResolve(storedType, out target)) return false;
+
+ var constraint = constraints.Resolve(assetPath, fileId, rid);
+ return constraint is null || constraint == typeof(object) || constraint.IsAssignableFrom(target);
+ }
+
+ ///
+ /// The ranked Smart Fix for a missing node, via the shared per-(path, fileId, rid) cache so a rescan and
+ /// the inline drawer reuse one computation.
+ ///
+ /// Best-effort: a parse miss just means no suggestion row.
+ public static bool TryGetSuggestion(string assetPath, long fileId, long rid, ManagedTypeName storedType,
+ SerializeReferenceConstraintCache constraints, out SerializeReferenceRepairSuggestions.RepairCandidate suggestion)
+ {
+ suggestion = default;
+
+ try
+ {
+ var fieldNames = SerializeReferenceYamlEditor.GetReferenceFieldNames(assetPath, fileId, rid);
+ var constraint = constraints.Resolve(assetPath, fileId, rid) ?? typeof(object);
+
+ var ranked = SerializeReferenceRepairSuggestions.GetCached(assetPath, fileId, rid,
+ () => SerializeReferenceRepairSuggestions.Rank(storedType, fieldNames, constraint));
+ if (ranked.Count == 0) return false;
+
+ suggestion = ranked[0];
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ private static void WalkForEmptySlots(ReferenceGraphDocument document, long rid, string pathLabel,
+ HashSet visited, HashSet<(long fileId, string path)> paths)
+ {
+ if (!visited.Add(rid)) return;
+
+ foreach (var edge in document.ChildrenOf(rid))
+ {
+ var childPath = CombinePath(pathLabel, edge.Label);
+ if (edge.IsEmpty)
+ paths.Add((document.FileId, SerializeReferenceGraphEditor.ToSerializedPropertyPath(childPath)));
+ else
+ WalkForEmptySlots(document, edge.Rid, childPath, visited, paths);
+ }
+
+ visited.Remove(rid);
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphAnalysis.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphAnalysis.cs.meta
new file mode 100644
index 00000000..e618def0
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphAnalysis.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0a39d1de45b54002a824716b318411bb
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphSummary.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphSummary.cs
new file mode 100644
index 00000000..d7b78688
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphSummary.cs
@@ -0,0 +1,90 @@
+using System.Collections.Generic;
+using static Aspid.FastTools.SerializeReferences.Editors.SerializeReferenceAuditUI;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ ///
+ /// The Asset References overview copy: the headline naming what the graph found, the dim hint under it and each
+ /// document header's count line. Pure string composition over already-tallied counts.
+ ///
+ ///
+ /// Every count enters here already partitioned by , because the
+ /// wording turns on the distinction: a pending [MovedFrom] migration is a stale file rather than a
+ /// breakage, and an empty slot is unassigned rather than broken — so neither may be phrased as "missing".
+ ///
+ internal static class SerializeReferenceGraphSummary
+ {
+ ///
+ /// The overview headline. Only non-zero parts make it, joined like the Project References results header — so
+ /// an asset carrying several finding kinds names all of them instead of hiding the rest in the hint.
+ ///
+ /// Missing references, EXCLUDING .
+ public static string BuildOverviewTitle(int broken, int orphans, int migrations, int required)
+ {
+ var parts = new List(4);
+ if (broken > 0) parts.Add(BuildCountText(broken, "missing reference"));
+ if (orphans > 0) parts.Add(BuildCountText(orphans, "orphaned reference"));
+ if (required > 0) parts.Add(BuildCountText(required, "required violation"));
+ if (migrations > 0) parts.Add(BuildCountText(migrations, "pending migration"));
+
+ return parts.Count > 0 ? string.Join(", ", parts) : "No missing references";
+ }
+
+ ///
+ /// The dim line under the headline: the mapped total, a breakdown of every finding kind, and the one action
+ /// that most needs doing.
+ ///
+ /// Missing references INCLUDING — the raw tally.
+ /// Unassigned slots that are allowed to stay empty; required ones are reported through
+ /// instead, never twice.
+ public static string BuildOverviewHint(int total, int missing, int orphans, int empties, int migrations, int required)
+ {
+ var references = total == 1 ? "1 managed reference" : $"{total} managed references";
+ var emptyNote = empties switch
+ {
+ 0 => string.Empty,
+ 1 => " · 1 unassigned field",
+ _ => $" · {empties} unassigned fields"
+ };
+
+ if (missing == 0 && orphans == 0 && required == 0)
+ return $"{references} mapped{emptyNote} — every [SerializeReference] type resolves.";
+
+ var broken = missing - migrations;
+
+ var parts = new List(5);
+ if (broken > 0) parts.Add(broken == 1 ? "1 missing type" : $"{broken} missing types");
+ if (migrations > 0) parts.Add(migrations == 1 ? "1 pending [MovedFrom] migration" : $"{migrations} pending [MovedFrom] migrations");
+ if (orphans > 0) parts.Add(orphans == 1 ? "1 orphaned rid" : $"{orphans} orphaned rids");
+ if (required > 0) parts.Add(required == 1 ? "1 required field unassigned" : $"{required} required fields unassigned");
+ if (empties > 0) parts.Add(empties == 1 ? "1 unassigned field" : $"{empties} unassigned fields");
+
+ var action = broken > 0
+ ? "Fix a missing type inline from its card."
+ : required > 0
+ ? "Assign each required field from its amber card."
+ : migrations > 0
+ ? "Migrate a renamed type from its card — the Inspector already loads it; only the file is stale."
+ : "Clear an orphaned rid from its card.";
+
+ return $"{references} mapped · {string.Join(" · ", parts)}. {action}";
+ }
+
+ ///
+ /// One document header's count line. A pending [MovedFrom] migration is named as such so the header
+ /// never contradicts the overview's "0 missing".
+ ///
+ public static string BuildDocumentCountText(ReferenceGraphDocument document, int broken, int migrations)
+ {
+ var total = document.Nodes.Count;
+ var orphans = document.Orphans.Count;
+
+ var text = total == 1 ? "1 reference" : $"{total} references";
+ if (broken > 0) text += $" · {broken} missing";
+ if (migrations > 0) text += migrations == 1 ? " · 1 migration" : $" · {migrations} migrations";
+ if (orphans > 0) text += orphans == 1 ? " · 1 orphaned" : $" · {orphans} orphaned";
+ return text;
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphSummary.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphSummary.cs.meta
new file mode 100644
index 00000000..de203574
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphSummary.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 717fe25883e54a56985a9f627c28c058
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Cards.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Cards.cs
new file mode 100644
index 00000000..143e1fd2
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Cards.cs
@@ -0,0 +1,165 @@
+using System;
+using UnityEngine.UIElements;
+using Aspid.FastTools.UIElements;
+using System.Collections.Generic;
+using Aspid.FastTools.UIElements.Editors.Internal;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ // Document-level layout: one collapsible card per serialized-object document, the walk that flattens its reference
+ // tree into a stack of sibling cards, and the trailing "Orphaned" group. Nesting is carried by each card's
+ // threaded field path, never by indentation — the individual cards are built by the .Nodes partial.
+ internal sealed partial class SerializeReferenceGraphView
+ {
+ private const string DocumentClass = RootClass + "__document";
+ private const string DocumentHeaderClass = RootClass + "__document-header";
+ private const string DocumentHeaderIssuesClass = DocumentHeaderClass + "--issues";
+ private const string DocumentHeaderRowClass = RootClass + "__document-header-row";
+ private const string DocumentTitleClass = RootClass + "__document-title";
+ private const string DocumentCountClass = RootClass + "__document-count";
+ private const string DocumentBodyClass = RootClass + "__document-body";
+
+ private const string OrphanGroupClass = RootClass + "__orphan-group";
+ private const string OrphanGroupHeaderClass = RootClass + "__orphan-group-header";
+
+ private const string DocumentChevronExpanded = "▼";
+ private const string DocumentChevronCollapsed = "▶";
+
+ // One serialized object document: a collapsible header band over a flat stack of node cards plus a trailing
+ // "Orphaned" group. The header is dropped for a single-document asset — there it would only restate the
+ // ObjectField above it.
+ private VisualElement BuildDocument(string assetPath, ReferenceGraphDocument document, bool showHeader)
+ {
+ // Pending migrations are not issues — a document whose only findings are migrations keeps the calm
+ // header, matching the info-toned overview; orphans and genuinely broken nodes still glow amber.
+ var (broken, migrations) = SerializeReferenceGraphAnalysis.CountUnresolved(assetPath, document, _constraints);
+ var hasIssues = document.Orphans.Count > 0 || broken > 0;
+
+ var body = new VisualElement().AddClass(DocumentBodyClass);
+
+ // The header is built (and registered on the nav ring) BEFORE the body cards, so the keyboard order
+ // matches the visual order — the band sits above the cards it collapses.
+ var header = showHeader ? BuildDocumentHeader(document, body, hasIssues, broken, migrations) : null;
+
+ // Missing roots render first. Two passes over the asset's field order keep the partition stable between
+ // rescans; empty (unassigned) roots are not missing, so they fall to the second pass.
+ foreach (var root in document.Roots)
+ {
+ if (root.IsEmpty || !SerializeReferenceGraphAnalysis.RootIsMissing(document, root.Rid)) continue;
+ AppendNode(body, assetPath, document, root.Rid, root.Label, new HashSet());
+ }
+
+ foreach (var root in document.Roots)
+ {
+ if (root.IsEmpty)
+ {
+ body.AddChild(BuildEmptySlotCard(assetPath, document.FileId, root.Label));
+ continue;
+ }
+
+ if (SerializeReferenceGraphAnalysis.RootIsMissing(document, root.Rid)) continue;
+ AppendNode(body, assetPath, document, root.Rid, root.Label, new HashSet());
+ }
+
+ var orphans = BuildOrphanGroup(assetPath, document);
+ if (orphans is not null) body.AddChild(orphans);
+
+ // Single-document asset: no header band — the ObjectField above already names it. Always expanded.
+ if (header is null)
+ return new VisualElement().AddClass(DocumentClass).AddChild(body);
+
+ return new VisualElement()
+ .AddClass(DocumentClass)
+ .AddChild(header)
+ .AddChild(body);
+ }
+
+ // The collapse band over a document's body. The self-reference lets the click handler flip its own chevron
+ // alongside toggling the body.
+ private AspidGradientButton BuildDocumentHeader(ReferenceGraphDocument document, VisualElement body,
+ bool hasIssues, int broken, int migrations)
+ {
+ var collapsed = false;
+ AspidGradientButton header = null;
+
+ var toggle = new Action(() =>
+ {
+ collapsed = !collapsed;
+ body.style.display = collapsed ? DisplayStyle.None : DisplayStyle.Flex;
+ header.Text = collapsed ? DocumentChevronCollapsed : DocumentChevronExpanded;
+ });
+
+ header = new AspidGradientButton(DocumentChevronExpanded, _ => toggle())
+ .AddClass(DocumentHeaderClass);
+ if (hasIssues) header.AddClass(DocumentHeaderIssuesClass);
+ header.tooltip = $"fileId {document.FileId}";
+ RegisterNavTarget(header, toggle);
+
+ // Ignored for picking so clicks fall through to the band's own handler.
+ header.AddLeadingContent(new VisualElement()
+ .AddClass(DocumentHeaderRowClass)
+ .SetPickingMode(PickingMode.Ignore)
+ .AddChild(new Label(document.TypeName)
+ .AddClass(DocumentTitleClass)
+ .SetPickingMode(PickingMode.Ignore))
+ .AddChild(new Label(SerializeReferenceGraphSummary.BuildDocumentCountText(document, broken, migrations))
+ .AddClass(DocumentCountClass)
+ .SetPickingMode(PickingMode.Ignore)));
+
+ return header;
+ }
+
+ // Appends a node's card and, recursively, its children's as flat siblings — nesting is carried by the threaded
+ // field path, not the layout. The visited set makes the walk cycle-safe: a rid already on the current path
+ // renders as a back-edge leaf instead of recursing forever.
+ private void AppendNode(VisualElement container, string assetPath, ReferenceGraphDocument document, long rid, string pathLabel, HashSet visited)
+ {
+ if (!visited.Add(rid))
+ {
+ container.AddChild(BuildBackEdgeCard(rid));
+ return;
+ }
+
+ var node = document.FindNode(rid);
+ container.AddChild(BuildNodeCard(assetPath, document, node, rid, pathLabel, isOrphan: false));
+
+ foreach (var edge in document.ChildrenOf(rid))
+ {
+ var childPath = SerializeReferenceGraphAnalysis.CombinePath(pathLabel, edge.Label);
+ if (edge.IsEmpty)
+ container.AddChild(BuildEmptySlotCard(assetPath, document.FileId, childPath));
+ else
+ AppendNode(container, assetPath, document, edge.Rid, childPath, visited);
+ }
+
+ // Leaving the recursion: drop the rid so a sibling subtree may legitimately reference it again (shared),
+ // while a back-edge on the current path is still caught above.
+ visited.Remove(rid);
+ }
+
+ // Warning-tinted group for rids no root reaches. Each orphan is a full node card (so a missing orphan is still
+ // fixable inline) with a footer Clear, without recursion into children.
+ private VisualElement BuildOrphanGroup(string assetPath, ReferenceGraphDocument document)
+ {
+ if (document.Orphans.Count == 0) return null;
+
+ var group = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
+ .AddClass(OrphanGroupClass);
+
+ group.AddChild(new AspidLabel("Orphaned", AspidLabelPreset.Default
+ .SetLabelStatus(StatusStyle.Type.Warning)
+ .SetLabelSize(AspidLabelSizeStyle.Type.H5)
+ .SetLineSize(AspidDividingLineSizeStyle.Type.None))
+ .AddClass(OrphanGroupHeaderClass));
+
+ foreach (var node in document.Nodes)
+ {
+ if (!document.Orphans.Contains(node.Rid)) continue;
+ group.AddChild(BuildNodeCard(assetPath, document, node, node.Rid, pathLabel: null, isOrphan: true));
+ }
+
+ return group;
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Cards.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Cards.cs.meta
new file mode 100644
index 00000000..d1f72edb
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Cards.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 1db680346b8c45e184c4b2df97338a6b
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Nodes.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Nodes.cs
new file mode 100644
index 00000000..32166bc7
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Nodes.cs
@@ -0,0 +1,405 @@
+using System;
+using UnityEngine.UIElements;
+using Aspid.FastTools.UIElements;
+using Aspid.FastTools.Types.Editors;
+using Aspid.FastTools.UIElements.Editors.Internal;
+using static Aspid.FastTools.SerializeReferences.Editors.SerializeReferenceAuditUI;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ // The individual cards a document's body is made of, and the parts they share. Four shapes, one look: a resolved
+ // or missing reference, an unassigned slot, a required field the graph has no node for, and a back-edge leaf that
+ // terminates a cycle. Which band a card gets — a YAML dropdown, a live-property dropdown, or a static line — is
+ // decided here; what the band's pick does belongs to the .Picker partial.
+ internal sealed partial class SerializeReferenceGraphView
+ {
+ private const string NodeClass = RootClass + "__node";
+ private const string NodeBackEdgeClass = NodeClass + "--back-edge";
+ private const string NodeEmptyClass = NodeClass + "--empty";
+ private const string NodeMigrateCardClass = NodeClass + "--migrate";
+ private const string NodeHeaderHoverClass = NodeClass + "--header-hover";
+ private const string NodeBandClass = RootClass + "__node-band";
+ private const string NodeBandMissingClass = NodeBandClass + "--missing";
+ private const string NodeBandMigrateClass = NodeBandClass + "--migrate";
+ private const string NodeBandRowClass = RootClass + "__node-band-row";
+ private const string NodeDividerClass = RootClass + "__node-divider";
+ private const string NodeSweepClass = RootClass + "__node-sweep";
+ private const string NodeSweepMissingClass = NodeSweepClass + "--missing";
+ private const string NodeSweepMigrateClass = NodeSweepClass + "--migrate";
+ private const string NodeActionClass = RootClass + "__node-action";
+ private const string NodeActionInfoClass = NodeActionClass + "--info";
+ private const string NodeHeaderClass = RootClass + "__node-header";
+ private const string NodeFooterClass = RootClass + "__node-footer";
+ private const string NodeRootLabelClass = RootClass + "__node-root-label";
+ private const string NodeTypeClass = RootClass + "__node-type";
+ private const string NodeRidClass = RootClass + "__node-rid";
+ private const string NodeBadgesClass = RootClass + "__node-badges";
+
+ private const string BadgeClass = RootClass + "__badge";
+ private const string BadgeSharedClass = BadgeClass + "--shared";
+
+ private const string ChipClass = RootClass + "__chip";
+ private const string ClearOrphanClass = RootClass + "__clear-orphan";
+
+ // Band verb + collapse chevron; the picker host swaps the chevron glyph alone, never the label.
+ private const string FixCollapsedText = "Fix Missing ▼";
+ private const string ChangeCollapsedText = "Change ▼";
+ private const string AssignCollapsedText = "Assign ▼";
+
+ // A required slot's band verb names what the amber is about: the field must be assigned, not merely can be.
+ private const string AssignRequiredCollapsedText = "Assign Required ▼";
+
+ // A pending-migration card is not missing (Unity migrates it in memory; only the file is stale), so no "Missing".
+ private const string MigrateFixCollapsedText = "Fix ▼";
+
+ // Single-sourced from the picker's "" option so an empty slot reads like a cleared field in the Inspector.
+ private const string EmptySlotText = TypeSelectorHelpers.NoneOption;
+
+ // A node card whose band is an inline dropdown: a missing card edits through the YAML, a healthy one through
+ // the live serialization API, an orphan keeps a static band plus a footer Clear. Cards are not indented —
+ // the field path alone carries the nesting.
+ private VisualElement BuildNodeCard(string assetPath, ReferenceGraphDocument document, ReferenceGraphNode? node, long rid, string pathLabel, bool isOrphan)
+ {
+ var missing = node is { Resolves: false, StoredType: { IsEmpty: false } };
+
+ // An authoritative [MovedFrom] rename is a pending migration, not a breakage: Unity loads the reference
+ // fine — only this file still stores the old name. Never for an orphan — nothing loads an orphan, so the
+ // in-memory migration argument does not hold.
+ Type migrationTarget = null;
+ var isMigration = missing && !isOrphan &&
+ SerializeReferenceGraphAnalysis.IsPendingMigration(assetPath, document.FileId, rid,
+ node.Value.StoredType, _constraints, out migrationTarget);
+
+ var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
+ .AddClass(NodeClass);
+ // Card-level modifier so card-wide states (the --picking accent frame, the picker's accent-follow rules)
+ // read the calm info tone on a migration card instead of the broken-card amber.
+ if (isMigration) card.AddClass(NodeMigrateCardClass);
+
+ var typePreset = AspidLabelPreset.Default
+ .SetLabelSize(AspidLabelSizeStyle.Type.H5)
+ .SetLineSize(AspidDividingLineSizeStyle.Type.None);
+ typePreset = isMigration
+ ? typePreset.SetLabelStatus(StatusStyle.Type.Info)
+ : missing || isOrphan
+ ? typePreset.SetLabelStatus(StatusStyle.Type.Warning)
+ : typePreset.SetLabelTheme(ThemeStyle.Type.Lightness);
+
+ var typeLabel = new AspidLabel(node?.ShortName ?? $"rid {rid}", typePreset)
+ .AddClass(NodeTypeClass)
+ .SetPickingMode(PickingMode.Ignore);
+ if (node is not null && !node.Value.StoredType.IsEmpty)
+ typeLabel.tooltip = node.Value.FullName;
+
+ var bandRow = BuildBandRow(typeLabel, BuildBadges(document, rid));
+
+ // The captured file id targets every edit at exactly this document's rid (rids collide across documents).
+ var fileId = document.FileId;
+
+ if (missing)
+ {
+ // A missing reference cannot be reassigned through the serialization API, so its edit goes through the
+ // YAML (keeping the orphaned payload).
+ AspidGradientButton band = null;
+ band = new AspidGradientButton(isMigration ? MigrateFixCollapsedText : FixCollapsedText,
+ _ => OpenMissingPicker(assetPath, fileId, rid, band))
+ .AddClass(NodeBandClass)
+ .AddClass(isMigration ? NodeBandMigrateClass : NodeBandMissingClass);
+ band.AddLeadingContent(bandRow);
+ card.AddChild(band);
+ RegisterNavBand(band, card, () => OpenMissingPicker(assetPath, fileId, rid, band));
+ AddBandDivider(card, band, isMigration ? NodeSweepMigrateClass : NodeSweepMissingClass);
+
+ var action = BuildQuickFixRow(assetPath, fileId, rid, node.Value.StoredType, isMigration, migrationTarget);
+ if (action is not null) card.AddChild(action);
+ }
+ else if (!isOrphan)
+ {
+ // A healthy reference edits through the live serialization API (keyed by the field path), so Unity
+ // rewrites — or, on , removes — the RefIds entry exactly as the Inspector would.
+ var graphPath = pathLabel;
+ AspidGradientButton band = null;
+ band = new AspidGradientButton(ChangeCollapsedText, _ => OpenLivePicker(assetPath, fileId, graphPath, band))
+ .AddClass(NodeBandClass);
+ band.AddLeadingContent(bandRow);
+ card.AddChild(band);
+ RegisterNavBand(band, card, () => OpenLivePicker(assetPath, fileId, graphPath, band));
+ AddBandDivider(card, band, sweepModifier: null);
+ }
+ else
+ {
+ // An orphan has no field pointing at it, so there is no live property to edit — its band stays static
+ // and the footer Clear (below) drops the dangling entry. The divider still splits band from footer,
+ // but with no hover source there is no sweep.
+ card.AddChild(bandRow);
+ AddBandDivider(card, band: null, sweepModifier: null);
+ }
+
+ // Healthy and empty slots are cleared through their band's picker (), so no separate button here.
+ var meta = BuildFooter(pathLabel, $"rid {rid}");
+
+ if (isOrphan)
+ {
+ // Drop a dangling RefIds entry no field points at. File edit, so it is confirmed and not undoable.
+ var clear = new AspidGradientButton("Clear", _ => ClearOrphan(assetPath, fileId, rid))
+ .AddClass(ClearOrphanClass);
+ RegisterNavTarget(clear, () => ClearOrphan(assetPath, fileId, rid));
+ meta.AddChild(clear);
+ }
+
+ card.AddChild(meta);
+
+ return card;
+ }
+
+ // An unassigned [SerializeReference] slot — a field whose pointer is the null sentinel (rid -2). Its band is
+ // still a dropdown assigning a type through the live serialization API; a slot whose field path could not be
+ // recovered stays static (nothing to target). A required slot wears the missing card's clothes — amber
+ // "" header and amber band accent, no badge — so every "fix this" card in the graph reads the same.
+ private VisualElement BuildEmptySlotCard(string assetPath, long fileId, string pathLabel)
+ {
+ var isRequired = IsFieldRequiredUnset(fileId, pathLabel);
+
+ var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
+ .AddClass(NodeClass);
+ if (!isRequired) card.AddClass(NodeEmptyClass);
+
+ // A plain Label on an ordinary empty slot so the --empty USS rule tints it; a required slot paints its
+ // own amber status via AspidLabel, exactly like a missing card's type header.
+ var typeLabel = isRequired
+ ? (VisualElement)BuildRequiredNoneLabel("Required reference is not set")
+ : new Label(EmptySlotText).AddClass(NodeTypeClass).SetPickingMode(PickingMode.Ignore);
+
+ var bandRow = BuildBandRow(typeLabel, badges: null);
+
+ if (string.IsNullOrEmpty(pathLabel))
+ {
+ // No recoverable field path to target — leave the slot a static "" leaf.
+ card.AddChild(bandRow);
+ AddBandDivider(card, band: null, sweepModifier: null);
+ }
+ else
+ {
+ // is a no-op here — the slot is already unset.
+ var graphPath = pathLabel;
+ AspidGradientButton band = null;
+ band = new AspidGradientButton(isRequired ? AssignRequiredCollapsedText : AssignCollapsedText,
+ _ => OpenLivePicker(assetPath, fileId, graphPath, band))
+ .AddClass(NodeBandClass);
+ if (isRequired) band.AddClass(NodeBandMissingClass);
+ band.AddLeadingContent(bandRow);
+ card.AddChild(band);
+ RegisterNavBand(band, card, () => OpenLivePicker(assetPath, fileId, graphPath, band));
+ AddBandDivider(card, band, isRequired ? NodeSweepMissingClass : null);
+ }
+
+ card.AddChild(BuildFooter(pathLabel, "unassigned"));
+
+ return card;
+ }
+
+ // Trailing cards for required violations the graph has no node for — a string / SerializableType required
+ // field is never threaded into RefIds, so SerializeReferenceGraphScanner never emits a document for a
+ // component whose only serialized-reference-worthy fields are these.
+ // Mirrors a required BuildEmptySlotCard line for line — amber "" header, amber "Assign ▼" band,
+ // "Component.field:" + "unassigned" on the footer — so a required string / SerializableType field reads
+ // exactly like a required managed-reference slot (and both echo the missing card's clothes). The pick writes
+ // the type's assembly-qualified name into the backing string; a scene asset cannot be object-loaded (see
+ // SerializeReferenceGraphEditor.TryResolveRequiredStringProperty), so its band stays a static line edited
+ // through the normal Inspector.
+ private VisualElement BuildRequiredOnlyCard(GateViolation violation, ViolationFieldLabels labels)
+ {
+ var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
+ .AddClass(NodeClass);
+
+ var bandRow = BuildBandRow(BuildRequiredNoneLabel("Required type is not set"), badges: null);
+
+ if (SerializeReferenceHelpers.IsScene(violation.AssetPath))
+ {
+ // Not reachable through the live serialization API — leave the band a static "" line.
+ card.AddChild(bandRow);
+ AddBandDivider(card, band: null, sweepModifier: null);
+ }
+ else
+ {
+ AspidGradientButton band = null;
+ band = new AspidGradientButton(AssignRequiredCollapsedText, _ => OpenRequiredStringPicker(violation, band))
+ .AddClass(NodeBandClass)
+ .AddClass(NodeBandMissingClass);
+ band.AddLeadingContent(bandRow);
+ card.AddChild(band);
+ RegisterNavBand(band, card, () => OpenRequiredStringPicker(violation, band));
+ AddBandDivider(card, band, NodeSweepMissingClass);
+ }
+
+ card.AddChild(BuildFooter(labels.Describe(violation), "unassigned"));
+
+ return card;
+ }
+
+ // A back-edge to a rid already on the current render path — a single dim, italic line (no footer) so cycles
+ // terminate visibly.
+ private static VisualElement BuildBackEdgeCard(long rid)
+ {
+ var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
+ .AddClass(NodeClass)
+ .AddClass(NodeBackEdgeClass);
+
+ card.AddChild(new VisualElement()
+ .AddClass(NodeHeaderClass)
+ .AddChild(new Label($"↩ rid {rid}")
+ .AddClass(NodeTypeClass)
+ .SetPickingMode(PickingMode.Ignore)));
+
+ return card;
+ }
+
+ // ---------------------------------------------------------------------------------------------------------
+ // Shared card parts
+ // ---------------------------------------------------------------------------------------------------------
+
+ // The band's content, docked into the band button (or standing alone on a static card). Ignored for picking
+ // so clicks fall through to the band's own handler.
+ private static VisualElement BuildBandRow(VisualElement typeLabel, VisualElement badges)
+ {
+ var row = new VisualElement()
+ .AddClass(NodeBandRowClass)
+ .AddChild(typeLabel);
+ if (badges is not null) row.AddChild(badges);
+ row.pickingMode = PickingMode.Ignore;
+ return row;
+ }
+
+ // The amber "" header a required card wears — the same clothes as a missing card's type header.
+ private static AspidLabel BuildRequiredNoneLabel(string tooltip)
+ {
+ var label = new AspidLabel(EmptySlotText, AspidLabelPreset.Default
+ .SetLabelStatus(StatusStyle.Type.Warning)
+ .SetLabelSize(AspidLabelSizeStyle.Type.H5)
+ .SetLineSize(AspidDividingLineSizeStyle.Type.None))
+ .AddClass(NodeTypeClass)
+ .SetPickingMode(PickingMode.Ignore);
+ label.tooltip = tooltip;
+ return label;
+ }
+
+ // No MISSING badge — the band action and amber type pill already carry it; only SHARED remains, dotted with
+ // the rid's own colour so an aliased pair is recognisable across cards.
+ private static VisualElement BuildBadges(ReferenceGraphDocument document, long rid)
+ {
+ var badges = new VisualElement()
+ .AddClass(NodeBadgesClass)
+ .SetPickingMode(PickingMode.Ignore);
+
+ if (!document.Shared.Contains(rid)) return badges;
+
+ var shared = new Label("SHARED").AddClass(BadgeClass).AddClass(BadgeSharedClass);
+ var chip = new VisualElement().AddClass(ChipClass);
+ chip.style.backgroundColor = SerializeReferenceRidColor.ForRid(rid);
+ shared.AddChild(chip);
+
+ return badges.AddChild(shared);
+ }
+
+ // The one-click row under a broken card's band: a pending [MovedFrom] migration if the stored type resolves to
+ // one, otherwise the ranked Smart Fix guess — or nothing when neither applies.
+ private VisualElement BuildQuickFixRow(string assetPath, long fileId, long rid, ManagedTypeName storedType,
+ bool isMigration, Type migrationTarget)
+ {
+ if (isMigration)
+ {
+ // The same YAML rewrite a picker pick performs — no confirm, matching the picker's own apply.
+ return BuildNodeActionRow(
+ $"Migrate → {migrationTarget.Name}",
+ $"This entry resolves to {migrationTarget.FullName} via its declared [MovedFrom] — Unity " +
+ "already migrates it in memory when the asset loads. Migrating rewrites the stored type " +
+ "name in the file so it matches the code.",
+ info: true,
+ () => ApplyFix(assetPath, fileId, rid, migrationTarget.AssemblyQualifiedName));
+ }
+
+ if (!SerializeReferenceGraphAnalysis.TryGetSuggestion(assetPath, fileId, rid, storedType, _constraints, out var suggestion))
+ return null;
+
+ // Safe to hand straight to ApplyFix: Rank's pool is constraint-filtered, so the suggestion is always a
+ // type the picker itself would offer.
+ return BuildNodeActionRow(
+ $"Smart Fix {SerializeReferenceHelpers.GetSuggestionLabel(suggestion)}",
+ SerializeReferenceHelpers.GetSuggestionDetail(suggestion),
+ info: false,
+ () => ApplyFix(assetPath, fileId, rid, suggestion.Type.AssemblyQualifiedName));
+ }
+
+ // A one-click action (Smart Fix / Migrate) as a flat accent verb over the same hover fill the Project
+ // References action rows use, instead of a filled gradient pill floating over the glass card. Each card
+ // keeps one accent: warning amber for a Smart Fix guess on a broken card, info for a pending migration.
+ private VisualElement BuildNodeActionRow(string text, string tooltipText, bool info, Action onClick)
+ {
+ var row = new Label(text).AddClass(NodeActionClass);
+ if (info) row.AddClass(NodeActionInfoClass);
+ row.tooltip = tooltipText;
+ row.RegisterCallback(_ => onClick());
+ RegisterNavTarget(row, onClick);
+ return row;
+ }
+
+ // The dim hairline between a card's band and its body, plus — when the band is interactive — the accent
+ // underline sweep that scales in while the band is hovered (the Project References group cards' idiom).
+ // The sweep is the band's sibling, so USS :hover can't reach it; it rides the card's --header-hover modifier
+ // instead, which the ring lights (see RegisterNavBand). Both hide while the picker is docked (see the
+ // --picking USS rules).
+ private static void AddBandDivider(VisualElement card, AspidGradientButton band, string sweepModifier)
+ {
+ card.AddChild(new AspidDividingLine(AspidDividingLinePreset.Default
+ .SetTheme(ThemeStyle.Type.Light)
+ .SetSize(AspidDividingLineSizeStyle.Type.Thin))
+ .AddClass(NodeDividerClass));
+
+ if (band is null) return;
+
+ var sweep = new VisualElement()
+ .AddClass(NodeSweepClass)
+ .SetPickingMode(PickingMode.Ignore);
+ if (sweepModifier is not null) sweep.AddClass(sweepModifier);
+ card.AddChild(sweep);
+ }
+
+ // Every card's footer: the field path it sits at (when one was recovered) plus its rid / status word, both
+ // selectable so they can be copied out.
+ private static VisualElement BuildFooter(string pathLabel, string trailingText)
+ {
+ var meta = new VisualElement().AddClass(NodeFooterClass);
+
+ if (!string.IsNullOrEmpty(pathLabel))
+ {
+ meta.AddChild(MakeSelectable(new Label($"{pathLabel}:")
+ .AddClass(NodeRootLabelClass)));
+ }
+
+ meta.AddChild(MakeSelectable(new Label(trailingText)
+ .AddClass(NodeRidClass)));
+
+ return meta;
+ }
+
+ // Matches an empty slot's graph field path (list indices as "[i]") against a GateViolation's FieldPath (Unity's
+ // native SerializedProperty form, "Array.data[i]") for the same document — the same normalization
+ // TryResolveLiveProperty already applies to reach the live property at this path. Best-effort: a slot whose
+ // path could not be recovered by the YAML walk (SerializeReferenceGraphScanner's "reference" fallback) never
+ // matches a real property path, so its badge is silently skipped rather than false-positiving — the same
+ // violation still shows correctly in the Project References tab.
+ private bool IsFieldRequiredUnset(long fileId, string pathLabel)
+ {
+ if (string.IsNullOrEmpty(pathLabel) || _requiredViolations.Count == 0) return false;
+
+ var propertyPath = SerializeReferenceGraphEditor.ToSerializedPropertyPath(pathLabel);
+ foreach (var violation in _requiredViolations)
+ {
+ if (violation.FileId == fileId && violation.FieldPath == propertyPath) return true;
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Nodes.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Nodes.cs.meta
new file mode 100644
index 00000000..5b60d4e7
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Nodes.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 32df7206c6454014874716cde9ab661d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Picker.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Picker.cs
new file mode 100644
index 00000000..ade979e5
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Picker.cs
@@ -0,0 +1,151 @@
+using System;
+using UnityEditor;
+using System.Collections.Generic;
+using Aspid.FastTools.Editors;
+using Aspid.FastTools.Types.Editors;
+using Aspid.FastTools.UIElements.Editors.Internal;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ // The inline type pickers and what a pick does. Three flavours, one per edit route: a missing entry is repaired
+ // through the YAML, a healthy or empty slot through the live serialization API, and a required string field
+ // through its backing string property. Each reads its candidate set and current value from the same source the
+ // matching apply writes to, so the picker can never offer a type the apply would reject. The edits themselves
+ // belong to SerializeReferenceGraphEditor — everything here only decides what to open and when to re-render.
+ internal sealed partial class SerializeReferenceGraphView
+ {
+ private const string PickerClass = RootClass + "__picker";
+ private const string PickerAttachedClass = PickerClass + "--attached";
+ private const string NodePickingClass = NodeClass + "--picking";
+
+ private static readonly AuditPickerHost.PickerClasses _pickerClassSet =
+ new(PickerClass, PickerAttachedClass, NodePickingClass);
+
+ // Missing card: constrained to the rid's declared field type so a repair cannot pick an incompatible type that
+ // would null on import; an unresolvable field type falls back to unconstrained.
+ private void OpenMissingPicker(string assetPath, long fileId, long rid, AspidGradientButton anchor) =>
+ TogglePicker(anchor, ManagedReferenceFilter.For(_constraints.Resolve(assetPath, fileId, rid)),
+ currentAqn: null, // a missing entry has no current value — nothing (not even ) wears the check
+ assemblyQualifiedName => ApplyFix(assetPath, fileId, rid, assemblyQualifiedName));
+
+ // Healthy / empty card: constraint and current type are read from the live property at the field path. A field
+ // the API cannot reach opens an unconstrained picker and surfaces the failure on apply.
+ private void OpenLivePicker(string assetPath, long fileId, string graphPath, AspidGradientButton anchor)
+ {
+ var constraint = typeof(object);
+ var currentAqn = string.Empty;
+
+ if (SerializeReferenceGraphEditor.TryResolveLiveProperty(assetPath, fileId, graphPath, out var serializedObject, out var property))
+ {
+ using (serializedObject)
+ {
+ constraint = SerializeReferenceHelpers.GetFieldType(property);
+ currentAqn = property.managedReferenceValue?.GetType().AssemblyQualifiedName ?? string.Empty;
+ }
+ }
+
+ TogglePicker(anchor, ManagedReferenceFilter.For(constraint), currentAqn,
+ assemblyQualifiedName => ApplyLive(assetPath, fileId, graphPath, assemblyQualifiedName));
+ }
+
+ // Required string / SerializableType card: constraint and current value are read from the live string
+ // property; a field the API cannot reach opens an unconstrained picker and surfaces the failure on apply
+ // (mirrors OpenLivePicker).
+ private void OpenRequiredStringPicker(GateViolation violation, AspidGradientButton anchor)
+ {
+ var filter = default(TypeSelectorFilter);
+ var currentAqn = string.Empty;
+
+ if (SerializeReferenceGraphEditor.TryResolveRequiredStringProperty(violation, out var serializedObject, out var property))
+ {
+ using (serializedObject)
+ {
+ currentAqn = property.stringValue ?? string.Empty;
+ filter = BuildRequiredStringFilter(serializedObject, property);
+ }
+ }
+
+ TogglePicker(anchor, filter, currentAqn,
+ assemblyQualifiedName => ApplyRequiredString(violation, assemblyQualifiedName));
+ }
+
+ // The same candidate set the field's own [TypeSelector] dropdown offers: the attribute's constraints resolved
+ // member-first against the owning object (TypeSelectorConstraintResolver), the wrapper's T for a
+ // SerializableType field, and the attribute's kind filter. Resolution warnings are the Inspector notice's
+ // concern — here an unresolvable constraint just widens the picker.
+ private static TypeSelectorFilter BuildRequiredStringFilter(SerializedObject serializedObject, SerializedProperty property)
+ {
+ if (!SerializeReferenceRequiredGate.TryGetRequired(property, out var selector)) return default;
+
+ var types = new List();
+
+ // The backing string of a SerializableType wrapper carries the wrapper's generic constraint.
+ var path = property.propertyPath;
+ var lastDotIndex = path.LastIndexOf('.');
+ if (lastDotIndex >= 0)
+ {
+ using var parentProperty = serializedObject.FindProperty(path[..lastDotIndex]);
+ var parentField = parentProperty?.GetFieldInfo();
+ if (parentField is not null &&
+ SerializableTypeUtility.TryGetBaseType(parentField.FieldType, out var wrapperBase) &&
+ wrapperBase is not null && wrapperBase != typeof(object))
+ types.Add(wrapperBase);
+ }
+
+ types.AddRange(TypeSelectorConstraintResolver.Resolve(
+ serializedObject.targetObject, selector.AssemblyQualifiedNames).Types);
+
+ return new TypeSelectorFilter
+ {
+ Types = types.Count > 0 ? types.ToArray() : null,
+ Allow = selector.Allow,
+ };
+ }
+
+ // The picker expands inline under the clicked card's band, one panel at a time. Generic over the source of
+ // truth: the caller supplies the candidate filter, the type to pre-navigate to, and what a pick does.
+ private void TogglePicker(AspidGradientButton anchor, TypeSelectorFilter filter, string currentAqn, Action onSelected)
+ {
+ if (_picker.ToggleClosed(anchor)) return;
+
+ _picker.Open(anchor, new TypeSelectorView(
+ filter: filter,
+ currentAqn: currentAqn, // null (no current-value concept) and "" (holds ) both pass through as-is
+ onSelected: onSelected,
+ onDismiss: _picker.Close));
+ }
+
+ // ---------------------------------------------------------------------------------------------------------
+ // Applying a pick — the edit is the editor's; only the re-render is this view's
+ // ---------------------------------------------------------------------------------------------------------
+
+ private void ApplyFix(string assetPath, long fileId, long rid, string assemblyQualifiedName)
+ {
+ if (SerializeReferenceGraphEditor.ApplyFix(assetPath, fileId, rid, assemblyQualifiedName)) Rescan();
+ }
+
+ private void ApplyLive(string assetPath, long fileId, string graphPath, string assemblyQualifiedName)
+ {
+ if (SerializeReferenceGraphEditor.ApplyLive(assetPath, fileId, graphPath, assemblyQualifiedName)) Rescan();
+ }
+
+ private void ApplyRequiredString(GateViolation violation, string assemblyQualifiedName)
+ {
+ if (SerializeReferenceGraphEditor.ApplyRequiredString(violation, assemblyQualifiedName)) Rescan();
+ }
+
+ private void ClearOrphan(string assetPath, long fileId, long rid)
+ {
+ if (SerializeReferenceGraphEditor.TryClearOrphan(assetPath, fileId, rid, out var staleRescan))
+ {
+ Rescan();
+ return;
+ }
+
+ // The on-screen graph was stale (the rid is no longer an orphan); re-render from the scan the editor
+ // already built instead of reading the unchanged file a second time.
+ if (staleRescan is not null) Rescan(staleRescan);
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Picker.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Picker.cs.meta
new file mode 100644
index 00000000..5c41186b
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.Picker.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c7bec962b9664f0f8f8aa9aa65f958cd
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.cs
new file mode 100644
index 00000000..7eaef285
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.cs
@@ -0,0 +1,428 @@
+using System;
+using UnityEditor;
+using UnityEngine.UIElements;
+using UnityEditor.UIElements;
+using Aspid.FastTools.Editors;
+using Aspid.FastTools.UIElements;
+using System.Collections.Generic;
+using System.Linq;
+using Aspid.FastTools.UIElements.Editors.Internal;
+using Object = UnityEngine.Object;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ ///
+ /// Asset-level visualiser for [SerializeReference] managed-reference graphs. For each serialized object
+ /// document in the asset it draws the reference tree — field-pointer roots, their nested children, shared
+ /// (aliased) references and orphaned payloads — straight from the YAML, so it surfaces references at any nesting
+ /// depth and the orphans the Inspector cannot navigate to. Every reference card is an inline type dropdown: the
+ /// same embedded picker the Repair window uses, anchored under the clicked card, where picking a type assigns /
+ /// re-points the reference and <None> clears it. Orphaned payloads no field reaches carry a
+ /// Clear action.
+ ///
+ ///
+ /// The implementation is split across partial files by concern: this file owns the chrome, the scan pass and the
+ /// overview; .Cards lays out each document and walks its tree; .Nodes builds the individual cards;
+ /// .Picker opens the inline type pickers and applies what they pick. The counting and copy live in the pure
+ /// / , and every edit is
+ /// performed by — this view only decides when to re-render.
+ ///
+ internal sealed partial class SerializeReferenceGraphView : VisualElement
+ {
+ private const string StyleSheetPath = "UI/SerializeReferences/Aspid-FastTools-ReferenceGraph";
+
+ private const string RootClass = "aspid-fasttools-reference-graph";
+ private const string ContentClass = RootClass + "__content";
+ private const string CardClass = RootClass + "__card";
+ private const string CardTitleClass = RootClass + "__card-title";
+ private const string CardDescriptionClass = RootClass + "__card-description";
+ private const string AssetClass = RootClass + "__asset";
+ private const string RescanClass = RootClass + "__rescan";
+ private const string EmptyClass = RootClass + "__empty";
+ private const string EmptyHiddenClass = EmptyClass + "--hidden";
+ private const string EmptyIconClass = RootClass + "__empty-icon";
+ private const string EmptyIconInfoClass = EmptyIconClass + "--info";
+ private const string EmptyTitleClass = RootClass + "__empty-title";
+ private const string EmptyMessageClass = RootClass + "__empty-message";
+ private const string ScrollClass = RootClass + "__scroll";
+ private const string ListClass = RootClass + "__list";
+ private const string ListHiddenClass = ListClass + "--hidden";
+
+ private const string OverviewClass = RootClass + "__overview";
+ private const string OverviewHiddenClass = OverviewClass + "--hidden";
+ private const string OverviewTitleClass = RootClass + "__overview-title";
+ private const string OverviewHintClass = RootClass + "__overview-hint";
+
+ private const string LegendClass = RootClass + "__legend";
+ private const string LegendHiddenClass = LegendClass + "--hidden";
+ private const string LegendItemClass = RootClass + "__legend-item";
+ private const string LegendDotClass = RootClass + "__legend-dot";
+ private const string LegendDotInfoClass = LegendDotClass + "--info";
+ private const string LegendTextClass = RootClass + "__legend-text";
+
+ private const string NavTargetClass = RootClass + "__nav-target";
+ private const string NavTargetFocusedClass = NavTargetClass + "--focused";
+
+ // Reports this view's state to the host window, which owns the shared dotted canvas behind every mode and
+ // washes it with the matching status.
+ private readonly Action _onCanvasStatus;
+
+ // Reports a target change to the host window: it rebuilds this view from its cached target on every tab switch,
+ // so without this an in-view pick would be dropped on the next return to this tab.
+ private readonly Action _onTargetChanged;
+
+ private Object _target;
+ private readonly ObjectField _assetField;
+ private readonly AspidGradientButton _rescanButton;
+ private readonly VisualElement _empty;
+ private readonly VisualElement _overview;
+ private readonly AspidLabel _overviewTitle;
+ private readonly Label _overviewHint;
+ private readonly VisualElement _legend;
+ private readonly VisualElement _list;
+ private readonly ScrollView _scroll;
+
+ // Keyboard navigation: one flat focus ring over every actionable element in visual order — Rescan first, then
+ // each document header, node band, action row and orphan Clear — shared with the other window tabs, so a
+ // member hidden inside a collapsed document band drops out of the ring (see NavRing).
+ private readonly NavRing _ring;
+
+ // The one inline picker, docked under whichever band opened it (see the .Picker partial).
+ private readonly AuditPickerHost _picker;
+
+ // Per-asset declared-field-type map, shared by every constraint question one render pass asks. Cleared on
+ // every Rescan so a rewritten file is re-read rather than answered from the pre-edit map.
+ private readonly SerializeReferenceConstraintCache _constraints = new();
+
+ // The legend's block-specific USS class names for the shared item builder.
+ private static readonly SerializeReferenceAuditUI.LegendClasses _legendClassSet =
+ new(LegendItemClass, LegendDotClass, LegendDotInfoClass, LegendTextClass);
+
+ // Unset [TypeSelector(Required = true)] fields for the current asset, refreshed on every Rescan. Populated
+ // straight from SerializeReferenceGateScanner — the same required-field check the Project References audit
+ // and the build/CI gate use — so the amber required styling here always agrees with them. A required
+ // string/SerializableType field has no rid and so no graph node; it gets its own trailing card instead
+ // (BuildRequiredOnlyCard).
+ private IReadOnlyList _requiredViolations = Array.Empty();
+
+ public SerializeReferenceGraphView(Object target, Action onCanvasStatus, Action onTargetChanged = null)
+ {
+ _target = target;
+ _onCanvasStatus = onCanvasStatus;
+ _onTargetChanged = onTargetChanged;
+
+ var root = this;
+ style.flexGrow = 1;
+ root.AddAspidThemeStyleSheets()
+ .AddStyleSheetsFromResource(StyleSheetPath)
+ .AddClass(RootClass);
+
+ var cardTitle = new AspidLabel("Inspect asset", AspidLabelPreset.Default
+ .SetLabelTheme(ThemeStyle.Type.Lightness)
+ .SetLabelSize(AspidLabelSizeStyle.Type.H5)
+ .SetLineSize(AspidDividingLineSizeStyle.Type.None))
+ .AddClass(CardTitleClass);
+
+ var cardDescription = new Label(
+ "Map a saved asset's [SerializeReference] graph and repair missing types inline.")
+ .AddClass(CardDescriptionClass);
+
+ _assetField = new ObjectField
+ {
+ objectType = typeof(Object),
+ allowSceneObjects = false,
+ value = _target,
+ };
+ _assetField.AddClass(AssetClass);
+ _assetField.RegisterValueChangedCallback(evt => SetTarget(evt.newValue));
+
+ // The field is hosted inside the Rescan button: swallow its presses so opening the object picker or
+ // dragging an asset in doesn't bubble to the button's Clickable and re-run Rescan.
+ _assetField.RegisterCallback(evt => evt.StopPropagation());
+
+ _rescanButton = new AspidGradientButton("Rescan", _ => Rescan())
+ .AddClass(RescanClass);
+ _rescanButton.AddTrailingContent(_assetField);
+ _rescanButton.FillWithTrailingContent();
+
+ var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
+ .AddClass(CardClass)
+ .AddChild(cardTitle)
+ .AddChild(cardDescription)
+ .AddChild(_rescanButton);
+
+ _empty = new VisualElement().AddClass(EmptyClass);
+
+ _overviewTitle = new AspidLabel(string.Empty, AspidLabelPreset.Default
+ .SetLabelStatus(StatusStyle.Type.Warning)
+ .SetLabelSize(AspidLabelSizeStyle.Type.H4)
+ .SetLineTheme(ThemeStyle.Type.Dark)
+ .SetLineStatus(StatusStyle.Type.Warning))
+ .AddClass(OverviewTitleClass);
+
+ _overviewHint = new Label(string.Empty).AddClass(OverviewHintClass);
+
+ // Color key for the two card accents; only shown when both are actually on screen (see ShowOverview) —
+ // the same amber/blue legend the Project References audit renders under its hint.
+ _legend = new VisualElement()
+ .AddClass(LegendClass)
+ .AddClass(LegendHiddenClass)
+ .AddChild(SerializeReferenceAuditUI.BuildLegendItem("Broken — pick a replacement", info: false, _legendClassSet))
+ .AddChild(SerializeReferenceAuditUI.BuildLegendItem("Renamed — one-click migrate", info: true, _legendClassSet));
+
+ _overview = new VisualElement()
+ .AddClass(OverviewClass)
+ .AddClass(OverviewHiddenClass)
+ .AddChild(_overviewTitle)
+ .AddChild(_overviewHint)
+ .AddChild(_legend);
+
+ _list = new VisualElement().AddClass(ListClass);
+
+ // One scroll spans the whole view, so the card and overview scroll away with the document list rather than
+ // staying pinned above a separately-scrolling list.
+ var content = new VisualElement()
+ .AddClass(ContentClass)
+ .AddChild(card)
+ .AddChild(_empty)
+ .AddChild(_overview)
+ .AddChild(_list);
+
+ _scroll = new ScrollView().AddClass(ScrollClass);
+ _scroll.AddChild(content);
+
+ root.AddChild(_scroll);
+
+ _picker = new AuditPickerHost(this, _list, _pickerClassSet);
+
+ // The shared keyboard ring: the root holds focus (grabbed on attach, re-grabbed when the picker closes)
+ // so keys reach it before anything is highlighted. Suspended while a type picker owns the keyboard.
+ _ring = new NavRing(
+ host: this,
+ navTargetClass: NavTargetClass,
+ focusedClass: NavTargetFocusedClass,
+ scrollTo: element => _scroll.ScrollTo(element),
+ isSuspended: () => _picker.IsOpen);
+
+ Rescan();
+ }
+
+ // ---------------------------------------------------------------------------------------------------------
+ // Keyboard navigation (mirrors SerializeReferenceProjectView)
+ // ---------------------------------------------------------------------------------------------------------
+
+ // Every render pass rebuilds the ring from scratch (the old elements are gone with _list.Clear()). The Rescan
+ // button outlives the render and re-registers here, so a highlight sitting on it comes back with it and
+ // Enter-on-Rescan keeps its highlight.
+ private void ResetNavTargets()
+ {
+ _ring.Clear(keepFocusedElement: true);
+ RegisterNavTarget(_rescanButton, () => Rescan());
+ }
+
+ private void RegisterNavTarget(VisualElement element, Action activate) => _ring.Register(element, activate);
+
+ // A node band is its card's header: the ring drives the underline sweep AddBandDivider hangs under it, so the
+ // sweep reads the same whether the band is hovered or holds the keyboard highlight.
+ private void RegisterNavBand(AspidGradientButton band, VisualElement card, Action activate) =>
+ _ring.RegisterHeader(band, card, NodeHeaderHoverClass, activate);
+
+ // ---------------------------------------------------------------------------------------------------------
+ // Scan pass
+ // ---------------------------------------------------------------------------------------------------------
+
+ private void SetTarget(Object target)
+ {
+ _target = target;
+ // Mirror the pick back to the host so its cached target follows; the host just stores it (no rebuild),
+ // so this never re-enters.
+ _onTargetChanged?.Invoke(target);
+ // Open() retargets an already-open window, so the field must follow the new target — without notifying,
+ // or the change callback would trigger a second scan.
+ _assetField?.SetValueWithoutNotify(target);
+ if (_list is not null) Rescan();
+ }
+
+ private void Rescan(List prebuilt = null)
+ {
+ if (_list is null) return;
+
+ _picker.Close();
+ // Drop the constraint maps so a rescan after a fix / clear re-reads the rewritten YAML, not a stale map.
+ _constraints.Clear();
+ _list.Clear();
+ ResetNavTargets();
+ _requiredViolations = Array.Empty();
+
+ var assetPath = _target ? AssetDatabase.GetAssetPath(_target) : null;
+ if (string.IsNullOrEmpty(assetPath))
+ {
+ if (!TryOfferSourcePrefab())
+ {
+ ShowEmpty(
+ "No asset selected",
+ "Select a saved asset (a prefab or ScriptableObject) to map its managed-reference graph.");
+ }
+
+ return;
+ }
+
+ var documents = prebuilt ?? SerializeReferenceGraphScanner.Build(assetPath);
+
+ // Same headless scanner as the Project References audit and the build/CI gate, scoped to this one asset.
+ // Read before the empty-graph bail: a string / SerializableType required field has no rid and so never
+ // produces a document (SerializeReferenceGraphScanner only emits one for an object with a RefIds block),
+ // so it can be the ONLY thing this asset has to show even when the managed-reference graph is empty.
+ _requiredViolations = SerializeReferenceGateScanner.ScanAssetRequiredFields(assetPath);
+
+ if (documents.Count == 0 && _requiredViolations.Count == 0)
+ {
+ ShowEmpty(
+ "No managed references",
+ "This asset has no [SerializeReference] managed references to map.");
+ return;
+ }
+
+ ShowResults();
+ RenderDocuments(assetPath, documents);
+ }
+
+ // A nested prefab instance keeps its managed-reference data in the source prefab, not the host, so offer to
+ // retarget the graph onto that source where the RefIds actually live.
+ private bool TryOfferSourcePrefab()
+ {
+ if (!SerializeReferenceHelpers.TryGetSourcePrefabPath(_target, out var sourcePath)) return false;
+
+ ShowResults();
+ _onCanvasStatus?.Invoke(StatusStyle.Type.Info);
+
+ var info = new AspidHelpBox(AspidHelpBoxPreset.Default.SetMessageType(HelpBoxMessageType.Info))
+ .SetMessage("This is a prefab instance — its managed references live in the source prefab.");
+ _list.AddChild(info);
+
+ void OpenSource() => SetTarget(AssetDatabase.LoadAssetAtPath(sourcePath));
+
+ var openSource = new AspidGradientButton("Open Source Prefab", _ => OpenSource());
+ RegisterNavTarget(openSource, OpenSource);
+ _list.AddChild(openSource);
+ return true;
+ }
+
+ // Paints every document card, tallies what they hold and reports one verdict to both the overview and the
+ // window canvas — so the headline's tint and the wash behind it can never disagree.
+ private void RenderDocuments(string assetPath, List documents)
+ {
+ // Empty (unassigned) slots are tallied separately: they are not broken, so they never tip the
+ // headline / canvas to amber — they only surface in the dim hint.
+ var total = 0;
+ var missing = 0;
+ var orphans = 0;
+ var empties = 0;
+ var migrations = 0;
+
+ // Every empty managed-reference slot's normalized path, gathered up front so the required-only cards
+ // below can tell "already badged on a graph card" apart from "no graph node exists for this field at
+ // all" (a string / SerializableType required field, or an empty slot under a document the scanner
+ // failed to reach) without re-walking the tree per violation.
+ var emptySlotPaths = SerializeReferenceGraphAnalysis.CollectEmptySlotPaths(documents);
+
+ var showHeaders = documents.Count > 1;
+ foreach (var document in documents)
+ {
+ _list.AddChild(BuildDocument(assetPath, document, showHeaders));
+
+ total += document.Nodes.Count;
+ var (broken, documentMigrations) = SerializeReferenceGraphAnalysis.CountUnresolved(assetPath, document, _constraints);
+ missing += broken + documentMigrations;
+ migrations += documentMigrations;
+ orphans += document.Orphans.Count;
+ empties += SerializeReferenceGraphAnalysis.CountEmptySlots(document);
+ }
+
+ // Fields the graph has no node for at all: string / SerializableType required fields (never threaded
+ // into RefIds) plus, defensively, any managed-reference violation the graph walk could not place.
+ var ungraphedRequired = _requiredViolations
+ .Where(violation => !emptySlotPaths.Contains((violation.FileId, violation.FieldPath)))
+ .ToList();
+
+ // The headline's "unassigned fields" note counts only slots that are allowed to stay empty — a required
+ // empty slot is reported through the required count instead, never twice.
+ var required = _requiredViolations.Count;
+ var graphedRequired = required - ungraphedRequired.Count;
+
+ // Pending migrations are not breakages — a graph whose only annotations are migrations reads info-blue,
+ // matching the Project References group card.
+ var status = SerializeReferenceAuditUI.ResolveStatus(missing - migrations, orphans, required, migrations);
+ ShowOverview(status, total, missing, orphans, Math.Max(0, empties - graphedRequired), migrations, required);
+
+ if (ungraphedRequired.Count > 0)
+ {
+ // One resolver for the whole batch: several violations commonly share a component, and it memoises
+ // the object loads per asset path.
+ var labels = new ViolationFieldLabels();
+ foreach (var violation in ungraphedRequired)
+ _list.AddChild(BuildRequiredOnlyCard(violation, labels));
+ }
+
+ _onCanvasStatus?.Invoke(status);
+ }
+
+ // ---------------------------------------------------------------------------------------------------------
+ // View states
+ // ---------------------------------------------------------------------------------------------------------
+
+ private void ShowEmpty(string title, string message)
+ {
+ HideOverview();
+ _list.AddClass(ListHiddenClass);
+ _empty.RemoveClass(EmptyHiddenClass);
+ _empty.Clear();
+ _onCanvasStatus?.Invoke(StatusStyle.Type.Info);
+
+ var icon = new VisualElement()
+ .AddClass(EmptyIconClass)
+ .AddClass(EmptyIconInfoClass);
+
+ _empty.AddChild(icon)
+ .AddChild(new AspidLabel(title, AspidLabelPreset.Default
+ .SetLabelTheme(ThemeStyle.Type.Lightness)
+ .SetLabelSize(AspidLabelSizeStyle.Type.H3)
+ .SetLineSize(AspidDividingLineSizeStyle.Type.None))
+ .AddClass(EmptyTitleClass))
+ .AddChild(new Label(message).AddClass(EmptyMessageClass));
+ }
+
+ private void ShowResults()
+ {
+ // The overview stays hidden here; only the document-graph path (RenderDocuments) re-shows it, so the
+ // prefab-instance branch that reuses ShowResults keeps the missing-reference headline suppressed.
+ HideOverview();
+ _empty.AddClass(EmptyHiddenClass);
+ _list.RemoveClass(ListHiddenClass);
+ }
+
+ private void ShowOverview(StatusStyle.Type status, int total, int missing, int orphans, int empties, int migrations, int required)
+ {
+ var broken = missing - migrations;
+
+ _overviewTitle.Text = SerializeReferenceGraphSummary.BuildOverviewTitle(broken, orphans, migrations, required);
+ _overviewTitle.LabelStatus = status;
+ _overviewTitle.LineStatus = status;
+
+ _overviewHint.text = SerializeReferenceGraphSummary.BuildOverviewHint(total, missing, orphans, empties, migrations, required);
+
+ // The amber/blue key only earns its row when both accents are on screen at once (see the Project
+ // References legend).
+ var hasAmber = broken > 0 || orphans > 0 || required > 0;
+ _legend.EnableInClassList(LegendHiddenClass, migrations == 0 || !hasAmber);
+
+ _overview.RemoveClass(OverviewHiddenClass);
+ }
+
+ private void HideOverview() => _overview?.AddClass(OverviewHiddenClass);
+
+ // Future work: a "Make unique" action on a SHARED node — cloning the aliased reference so the two fields no
+ // longer affect each other (mirrors SerializeReferenceHelpers.MakeReferenceUnique).
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceGraphView.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.cs.meta
similarity index 100%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceGraphView.cs.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Asset/SerializeReferenceGraphView.cs.meta
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project.meta
new file mode 100644
index 00000000..0104e765
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 26caa3957f094074a9b8de4b1d2f7cff
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectSummary.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectSummary.cs
new file mode 100644
index 00000000..02106cf9
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectSummary.cs
@@ -0,0 +1,121 @@
+using System.Text;
+using System.Collections.Generic;
+using static Aspid.FastTools.SerializeReferences.Editors.SerializeReferenceAuditUI;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ ///
+ /// The Project References audit's copy: the results headline and hint, each group card's count line and band
+ /// label, and the capped before/after previews the bulk confirmations show. Pure string composition.
+ ///
+ internal static class SerializeReferenceProjectSummary
+ {
+ // Beyond this a confirmation dialog stops being readable, so the rest is reported as a remainder line.
+ private const int MaxPreviewedEntries = 8;
+
+ ///
+ /// The results headline. Only non-zero parts make it, and excludes the pending
+ /// migrations — a [MovedFrom] rename with a one-click fix shouldn't inflate the alarm number.
+ ///
+ public static string BuildResultsHeaderText(int brokenCount, int migrationCount, int requiredCount)
+ {
+ var parts = new List(3);
+ if (brokenCount > 0) parts.Add(BuildCountText(brokenCount, "missing reference"));
+ if (migrationCount > 0) parts.Add(BuildCountText(migrationCount, "pending migration"));
+ if (requiredCount > 0) parts.Add(BuildCountText(requiredCount, "required violation"));
+
+ return string.Join(", ", parts);
+ }
+
+ /// The dim line under the headline, naming what a card's affordances do.
+ public static string BuildResultsHintText(bool hasRequiredViolations)
+ {
+ const string hint = "Each group is a broken stored type — Fix all re-points its every entry to one replacement, or to .";
+
+ return hasRequiredViolations
+ ? hint + " Click a required-violation row to jump to its asset."
+ : hint;
+ }
+
+ /// A group card's "N entries · M files" line.
+ public static string BuildGroupCountText(MissingReferenceGroup group)
+ {
+ var entries = group.Entries.Count;
+ var files = group.FileCount;
+ var entryText = entries == 1 ? "1 entry" : $"{entries} entries";
+ var fileText = files == 1 ? "1 file" : $"{files} files";
+ return $"{entryText} · {fileText}";
+ }
+
+ ///
+ /// A group card's band label: the verb plus a trailing chevron the picker host swaps in place.
+ ///
+ ///
+ /// A broken group's picker fixes ("Fix all"); on a migration card nothing is broken and the picker is the
+ /// manual escape hatch beside the one-click Migrate all row, so its verb is "Reassign all".
+ ///
+ public static string BuildFixAllLabel(MissingReferenceGroup group, bool isMigration) =>
+ $"{(isMigration ? "Reassign all" : "Fix all")} ({group.Entries.Count}) ▼";
+
+ ///
+ /// The old → new preview of the YAML a bulk fix will rewrite, using the same TryComputeRewrite the
+ /// rewrite applies, so the preview is exactly what gets written.
+ ///
+ public static string BuildDiffPreview(IReadOnlyList entries, ManagedTypeName newType)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("Changes:");
+
+ // Compute first, render second: an uncomputable entry must neither vanish silently nor inflate the
+ // "…and N more" remainder.
+ var edits = new List<(MissingReferenceLocation entry, RewriteEdit edit)>(entries.Count);
+ foreach (var entry in entries)
+ {
+ if (SerializeReferenceYamlEditor.TryComputeRewrite(entry.AssetPath, entry.Entry.FileId, entry.Entry.Rid, newType, out var edit))
+ edits.Add((entry, edit));
+ }
+
+ for (var i = 0; i < edits.Count && i < MaxPreviewedEntries; i++)
+ {
+ var (entry, edit) = edits[i];
+ builder.AppendLine($" {System.IO.Path.GetFileName(entry.AssetPath)} (rid {entry.Entry.Rid}):");
+ builder.AppendLine($" - {edit.OldLine.Trim()}");
+ builder.AppendLine($" + {edit.NewLine.Trim()}");
+ }
+
+ if (edits.Count > MaxPreviewedEntries)
+ builder.AppendLine($" …and {edits.Count - MaxPreviewedEntries} more");
+
+ var uncomputable = entries.Count - edits.Count;
+ if (uncomputable > 0)
+ builder.AppendLine($" ({uncomputable} entr{(uncomputable == 1 ? "y" : "ies")} could not be previewed)");
+
+ builder.AppendLine();
+ return builder.ToString();
+ }
+
+ /// The capped file + rid list for a clear confirmation. No before/after lines — the whole entry is being dropped.
+ public static string BuildClearPreview(IReadOnlyList entries)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("Clears:");
+
+ var shown = 0;
+ foreach (var entry in entries)
+ {
+ if (shown >= MaxPreviewedEntries)
+ {
+ builder.AppendLine($" …and {entries.Count - shown} more");
+ break;
+ }
+
+ builder.AppendLine($" {System.IO.Path.GetFileName(entry.AssetPath)} (rid {entry.Entry.Rid})");
+ shown++;
+ }
+
+ builder.AppendLine();
+ return builder.ToString();
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectSummary.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectSummary.cs.meta
new file mode 100644
index 00000000..0d125594
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectSummary.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d4b94b63ed4d47f0b85da30c6d4b62af
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.Actions.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.Actions.cs
new file mode 100644
index 00000000..4eba34f5
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.Actions.cs
@@ -0,0 +1,235 @@
+using System;
+using UnityEditor;
+using UnityEngine.UIElements;
+using System.Collections.Generic;
+using Aspid.FastTools.Types.Editors;
+using Aspid.FastTools.UIElements.Editors.Internal;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ // The three bulk mutations a group card offers — rewrite every entry to one picked type, clear them all to null,
+ // and undo a rewrite from its receipt — plus the inline picker they hang off. Each one confirms with a preview
+ // computed by the very scan the write applies, leaves a receipt in the summary stack, and hands the file work to
+ // SerializeReferenceBatchEditor; only the confirmation copy and the re-render live here.
+ internal sealed partial class SerializeReferenceProjectView
+ {
+ private const string PickerClass = RootClass + "__picker";
+ private const string PickerAttachedClass = PickerClass + "--attached";
+ private const string GroupPickingClass = GroupClass + "--picking";
+
+ private static readonly AuditPickerHost.PickerClasses _pickerClassSet =
+ new(PickerClass, PickerAttachedClass, GroupPickingClass);
+
+ // The group's bulk picker, inline below the Fix all button, constrained to the group's intersected field type.
+ private void ToggleGroupPicker(MissingReferenceGroup group, Type constraint, AspidGradientButton button)
+ {
+ if (_picker.ToggleClosed(button)) return;
+
+ _picker.Open(button, new TypeSelectorView(
+ filter: ManagedReferenceFilter.For(constraint),
+ currentAqn: null, // the bulk group picker has no current value — nothing (not even ) wears the check
+ onSelected: assemblyQualifiedName =>
+ {
+ // emits an empty name: clear the group to null instead of treating it as a no-op.
+ if (string.IsNullOrEmpty(assemblyQualifiedName))
+ {
+ ClearGroupToNull(group);
+ return;
+ }
+
+ var type = Type.GetType(assemblyQualifiedName, throwOnError: false);
+ if (type is not null) ApplyGroupFix(group, type);
+ },
+ onDismiss: _picker.Close));
+ }
+
+ // Rewrites every entry in the group to newType after a mandatory confirmation.
+ private void ApplyGroupFix(MissingReferenceGroup group, Type newType)
+ {
+ if (newType is null) return;
+ _picker.Close();
+
+ var entries = SerializeReferenceBatchEditor.FilterWritable(group.Entries, out var skipped);
+
+ if (entries.Count == 0)
+ {
+ EditorUtility.DisplayDialog(
+ "Repair Missing References",
+ "All references in this group live in open scene(s) or Prefab Mode. Close them and rescan, " +
+ "or repair the fields directly in the Inspector.",
+ "OK");
+ return;
+ }
+
+ var files = SerializeReferenceBatchEditor.CountFiles(entries);
+ var skippedNote = skipped > 0
+ ? $"\n\n{skipped} reference(s) in open scene(s) or Prefab Mode will be skipped."
+ : string.Empty;
+
+ // When the group's picker fell back to an unconstrained list because its entries' declared field types
+ // disagree, the single chosen type cannot fit every entry — warn that the mismatched ones null on reimport.
+ group.ResolveConstraint(out var mixedFieldTypes);
+ var mixedNote = mixedFieldTypes
+ ? "\n\nField types in this group differ — the chosen type may not fit every entry; incompatible ones " +
+ "will become null on reimport."
+ : string.Empty;
+
+ var managedType = ManagedTypeName.FromType(newType);
+
+ // The preview is computed by the same scan the rewrite applies, so it shows exactly what gets written.
+ var diff = SerializeReferenceProjectSummary.BuildDiffPreview(entries, managedType);
+
+ if (!EditorUtility.DisplayDialog(
+ "Repair Missing References",
+ $"Rewrite {entries.Count} reference(s) in {files} file(s) to '{newType.FullName}'?\n\n" +
+ diff +
+ "This edits the asset files directly; an Undo button on the summary can revert it." + skippedNote + mixedNote,
+ "Rewrite",
+ "Cancel"))
+ return;
+
+ var rewritten = SerializeReferenceBatchEditor.Rewrite(entries, managedType, "Repairing References");
+
+ SerializeReferenceRepairSuggestions.ClearCache();
+
+ var summaryTitle = rewritten == 1 ? "Rewrote 1 reference" : $"Rewrote {rewritten} references";
+ var summaryBody = $"Replaced missing '{group.DisplayName}' with '{newType.FullName}'.";
+ if (skipped > 0)
+ summaryBody += $" Skipped {skipped} in open scene(s) or Prefab Mode.";
+
+ // Undo re-points the entries back to the original (now-missing) stored type. Only the type line moved —
+ // the data blocks were never touched on disk — so flipping it back is a faithful revert.
+ var originalType = group.StoredType;
+ var missingName = group.DisplayName;
+ var appliedName = newType.FullName;
+ void Undo(VisualElement receipt) => UndoGroupFix(entries, originalType, managedType, missingName, appliedName, receipt);
+
+ RerenderAfterBulkEdit();
+ ShowSummary(summaryTitle, summaryBody, Undo);
+ }
+
+ // Clears every entry in the group to null. Closed assets are nulled in the YAML directly; assets open in
+ // Prefab Mode / a loaded scene cannot be rewritten on disk (the open copy would clobber it on save), so those
+ // are nulled on the live object and stay in the audit until saved. NOT undoable: the broken payload is discarded.
+ private void ClearGroupToNull(MissingReferenceGroup group)
+ {
+ _picker.Close();
+
+ SerializeReferenceBatchEditor.SplitWritable(group.Entries, out var onDisk, out var inMemory);
+ if (onDisk.Count == 0 && inMemory.Count == 0) return;
+
+ var fileCount = SerializeReferenceBatchEditor.CountFiles(onDisk);
+ var total = onDisk.Count + inMemory.Count;
+
+ var openNote = inMemory.Count > 0
+ ? $"\n\n{inMemory.Count} reference(s) are open in Prefab Mode or a scene — those are nulled on the live " +
+ "object and saved with the asset (the audit keeps listing them until you save)."
+ : string.Empty;
+ var diskNote = onDisk.Count > 0
+ ? $" {onDisk.Count} on disk in {fileCount} file(s) are edited directly."
+ : string.Empty;
+
+ if (!EditorUtility.DisplayDialog(
+ "Clear Missing References",
+ $"Clear {total} reference(s) to null?\n\n" +
+ SerializeReferenceProjectSummary.BuildClearPreview(group.Entries) +
+ $"This nulls every field holding the broken '{group.DisplayName}' and discards its payload." +
+ diskNote + " It cannot be undone." + openNote,
+ "Clear",
+ "Cancel"))
+ return;
+
+ var clearedOnDisk = SerializeReferenceBatchEditor.Null(onDisk, "Clearing References");
+ var clearedInMemory = SerializeReferenceBatchEditor.ClearOpenInMemory(inMemory, group.StoredType);
+ var cleared = clearedOnDisk + clearedInMemory;
+
+ SerializeReferenceRepairSuggestions.ClearCache();
+
+ // Nothing actually changed (every edit failed) — skip the receipt rather than claim a cleared count of 0.
+ if (cleared == 0)
+ {
+ if (_scanButton is not null) _scanButton.Text = RescanLabel;
+ RenderGroups(MissingReferenceGroup.CollectFromIndex(), RequiredViolationsForRender);
+ return;
+ }
+
+ var summaryTitle = cleared == 1 ? "Cleared 1 reference" : $"Cleared {cleared} references";
+ var summaryBody = $"Set missing '{group.DisplayName}' to null.";
+ if (clearedInMemory > 0)
+ {
+ summaryBody += clearedInMemory == 1
+ ? " 1 was nulled in memory — save the asset to persist it (still listed until saved)."
+ : $" {clearedInMemory} were nulled in memory — save the assets to persist them (still listed until saved).";
+ }
+
+ // Unlike Fix all (which only swaps a stored type name, never nulls anything), Clear to null CAN turn a
+ // required field that held a broken-but-non-null reference into a genuine unset-required violation — drop
+ // the stale cache so the Required violations card doesn't under-report until the user rescans.
+ _requiredIsWarm = false;
+
+ RerenderAfterBulkEdit();
+
+ // No Undo: clearing discards the broken payload (see above). The receipt is a plain record.
+ ShowSummary(summaryTitle, summaryBody, onUndo: null);
+ }
+
+ // Reverts one bulk fix by re-pointing its entries back to the original (now-missing) stored type. Only this
+ // fix's own receipt is dropped — receipts for other still-applied fixes survive, unlike a full Rescan.
+ private void UndoGroupFix(IReadOnlyList entries, ManagedTypeName originalType,
+ ManagedTypeName appliedType, string missingName, string appliedName, VisualElement receipt)
+ {
+ // The asset may have opened in a scene / Prefab Mode since the fix; apply the same guard as the forward fix.
+ var writable = SerializeReferenceBatchEditor.FilterWritable(entries, out var skipped);
+
+ // Only entries that STILL hold the type this receipt applied may be re-pointed — the group can have been
+ // re-broken and fixed to a DIFFERENT type since, and blindly rewriting would destroy that newer fix.
+ var revertible = SerializeReferenceBatchEditor.FilterStillHolding(writable, appliedType, out var diverged);
+
+ if (revertible.Count == 0)
+ {
+ EditorUtility.DisplayDialog(
+ "Undo Repair",
+ diverged > 0
+ ? "These references no longer hold the type this fix applied (they were re-pointed or removed " +
+ "since), so there is nothing this undo can safely revert."
+ : "These references now live in open scene(s) or Prefab Mode. Close them and try the undo again.",
+ "OK");
+ return;
+ }
+
+ var files = SerializeReferenceBatchEditor.CountFiles(revertible);
+ var skippedNote = skipped > 0
+ ? $"\n\n{skipped} reference(s) in open scene(s) or Prefab Mode will be skipped."
+ : string.Empty;
+ var divergedNote = diverged > 0
+ ? $"\n\n{diverged} reference(s) no longer hold '{appliedName}' (changed since this fix) and will be left alone."
+ : string.Empty;
+
+ if (!EditorUtility.DisplayDialog(
+ "Undo Repair",
+ $"Re-point {revertible.Count} reference(s) in {files} file(s) back to the missing '{missingName}'?\n\n" +
+ $"This restores the broken state you had before replacing it with '{appliedName}', and edits the " +
+ "asset files directly." + skippedNote + divergedNote,
+ "Undo",
+ "Cancel"))
+ return;
+
+ var reverted = SerializeReferenceBatchEditor.Rewrite(revertible, originalType, "Undoing Repair");
+
+ SerializeReferenceRepairSuggestions.ClearCache();
+
+ // Drop only this receipt — the others describe fixes still applied. RenderGroups rebuilds only _list,
+ // never _summaries, so the surviving receipts stay put.
+ receipt?.RemoveFromHierarchy();
+ RenderGroups(MissingReferenceGroup.CollectFromIndex(), RequiredViolationsForRender);
+
+ // The rewrite can come up short if a file changed between the check and the write — report the real count.
+ var undoTitle = reverted == 1 ? "Reverted 1 reference" : $"Reverted {reverted} references";
+ var undoBody = $"Re-pointed back to the missing '{missingName}'.";
+ if (diverged > 0) undoBody += $" Left {diverged} alone (no longer '{appliedName}').";
+ if (reverted < revertible.Count) undoBody += $" {revertible.Count - reverted} could not be rewritten.";
+ ShowSummary(undoTitle, undoBody, null);
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.Actions.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.Actions.cs.meta
new file mode 100644
index 00000000..5ccdaf56
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.Actions.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: febc27c068e3466ba6ccb41901319fbb
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.Cards.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.Cards.cs
new file mode 100644
index 00000000..d4a9b7cc
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.Cards.cs
@@ -0,0 +1,284 @@
+using System;
+using System.Linq;
+using UnityEditor;
+using UnityEngine.UIElements;
+using Aspid.FastTools.UIElements;
+using System.Collections.Generic;
+using UnityEditor.SceneManagement;
+using Aspid.FastTools.UIElements.Editors.Internal;
+using static Aspid.FastTools.SerializeReferences.Editors.SerializeReferenceAuditUI;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ // Card building: one card per broken stored type, whose whole header is the bulk picker, plus the read-only
+ // Required violations card. Entry rows are deliberately not individually fixable here — the per-row Fix affordance
+ // is reserved for the Asset References graph, and a row's only affordance is jumping to its asset.
+ internal sealed partial class SerializeReferenceProjectView
+ {
+ private const string GroupClass = RootClass + "__group";
+ private const string GroupMigrateClass = GroupClass + "--migrate";
+ private const string GroupHeaderHoverClass = GroupClass + "--header-hover";
+ private const string GroupDividerClass = RootClass + "__group-divider";
+ private const string GroupSweepClass = RootClass + "__group-sweep";
+ private const string GroupSweepMigrateClass = GroupSweepClass + "--migrate";
+ private const string GroupHeaderRowClass = RootClass + "__group-header-row";
+ private const string GroupHeaderRowStaticClass = GroupHeaderRowClass + "--static";
+ private const string GroupHeaderClass = RootClass + "__group-header";
+ private const string GroupCountClass = RootClass + "__group-count";
+ private const string GroupFixAllClass = RootClass + "__group-fix-all";
+ private const string GroupFixAllMigrateClass = GroupFixAllClass + "--migrate";
+ private const string GroupActionClass = RootClass + "__group-action";
+ private const string GroupActionInfoClass = GroupActionClass + "--info";
+ private const string GroupEntryClass = RootClass + "__group-entry";
+ private const string GroupEntryPathClass = RootClass + "__group-entry-path";
+ private const string GroupEntryRidClass = RootClass + "__group-entry-rid";
+ private const string GroupEntryFieldClass = RootClass + "__group-entry-field";
+
+ // A broken-type group card: the whole header is one clickable row that toggles the type picker, with the bulk
+ // "Fix all (N) ▼" action on the right.
+ private VisualElement BuildGroupCard(MissingReferenceGroup group, MissingReferenceMigration migration)
+ {
+ var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
+ .AddClass(GroupClass);
+
+ // Constraint + migration were resolved once in RenderGroups (see MissingReferenceMigration) and are reused
+ // here so the card can never disagree with the partition. A migration is an authoritative [MovedFrom]
+ // rename: Unity already migrates these in memory at load — only the files still store the old name.
+ var constraint = migration.Constraint;
+ var isMigration = migration.IsMigration;
+
+ // Card-level modifier so card-wide states (the --picking accent frame) can follow the card's own
+ // accent — a migration card is info-toned end to end, never the broken-card amber.
+ if (isMigration) card.AddClass(GroupMigrateClass);
+
+ // Built first so the type name + count can be docked into its body; the captured local is assigned before use.
+ AspidGradientButton fixAll = null;
+ fixAll = new AspidGradientButton(SerializeReferenceProjectSummary.BuildFixAllLabel(group, isMigration),
+ _ => ToggleGroupPicker(group, constraint, fixAll))
+ .AddClass(GroupFixAllClass);
+ // A migration card keeps its calm info tone end to end — the amber Fix all accent is the "broken" alarm.
+ if (isMigration) fixAll.AddClass(GroupFixAllMigrateClass);
+ // Registered as the card's header, so the ring drives the underline sweep added below from both the
+ // keyboard highlight and the mouse hover.
+ _ring.RegisterHeader(fixAll, card, GroupHeaderHoverClass, () => ToggleGroupPicker(group, constraint, fixAll));
+ fixAll.tooltip = constraint == typeof(object)
+ ? $"{group.DisplayName}\nMixed or unresolvable field types — the picker is unconstrained (any managed-reference type)."
+ : $"{group.DisplayName}\nConstrained to {constraint.FullName}.";
+
+ fixAll.AddLeadingContent(BuildGroupHeaderRow(
+ group.StoredType.Class,
+ SerializeReferenceProjectSummary.BuildGroupCountText(group),
+ isMigration ? StatusStyle.Type.Info : StatusStyle.Type.Warning,
+ isStatic: false));
+ card.AddChild(fixAll);
+
+ AddGroupDivider(card, withSweep: true, isMigration ? GroupSweepMigrateClass : null);
+
+ var action = BuildBulkActionRow(group, migration);
+ if (action is not null) card.AddChild(action);
+
+ foreach (var entry in group.Entries)
+ card.AddChild(BuildGroupEntryRow(entry));
+
+ return card;
+ }
+
+ // The one-click row under a card's header: a pending [MovedFrom] migration if the group is one, otherwise the
+ // ranked Smart Fix guess — or nothing when neither applies.
+ private VisualElement BuildBulkActionRow(MissingReferenceGroup group, MissingReferenceMigration migration)
+ {
+ if (migration.IsMigration)
+ {
+ var target = migration.Target;
+
+ // Not a guess, so it replaces the Smart Fix row: same confirm + diff preview + Undo flow as a picked fix.
+ return BuildGroupActionRow(
+ $"Migrate all ({group.Entries.Count}) → {target.Name}",
+ $"Every entry resolves to {target.FullName} via its declared [MovedFrom] — Unity already " +
+ "migrates them in memory when the asset loads. Migrating rewrites the stored type name in the " +
+ "files so they match the code; the attribute can be removed once no file stores the old name.",
+ info: true,
+ () => ApplyGroupFix(group, target));
+ }
+
+ if (!group.TryGetSuggestion(migration.Constraint, out var suggestion)) return null;
+
+ // Reuse the shared label/detail builders so the Smart Fix copy never drifts from the inspector notice.
+ return BuildGroupActionRow(
+ $"Smart Fix {SerializeReferenceHelpers.GetSuggestionLabel(suggestion)}",
+ SerializeReferenceHelpers.GetSuggestionDetail(suggestion),
+ info: false,
+ () => ApplyGroupFix(group, suggestion.Type));
+ }
+
+ // A one-click bulk action (Smart Fix / Migrate all) as a member of the entry-row family: a left-aligned
+ // accent verb over the same flat hover fill as the ping rows below it, instead of a filled gradient pill
+ // floating over the glass card. Each card keeps one accent: warning amber for a Smart Fix guess on a
+ // broken card, info for a pending migration.
+ private VisualElement BuildGroupActionRow(string text, string tooltipText, bool info, Action onClick)
+ {
+ var row = new Label(text).AddClass(GroupActionClass);
+ if (info) row.AddClass(GroupActionInfoClass);
+ row.tooltip = tooltipText;
+ row.RegisterCallback(_ => onClick());
+ RegisterNavTarget(row, onClick);
+ return row;
+ }
+
+ // Flat read-only list of every unset [TypeSelector(Required = true)] field, fed by the same headless scanner
+ // as the build/CI gate. No bulk fix here — unlike a broken type, an empty required field has nothing sensible
+ // to auto-assign, so the row's only affordance is jumping to the offending asset (where the graph's inline
+ // Assign Required picker lives).
+ private VisualElement BuildRequiredGroupCard(IReadOnlyList violations)
+ {
+ var card = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
+ .AddClass(GroupClass);
+
+ var files = violations.Select(violation => violation.AssetPath).Distinct(StringComparer.Ordinal).Count();
+
+ card.AddChild(BuildGroupHeaderRow(
+ "Required violations",
+ $"{BuildCountText(violations.Count, "entry")} · {(files == 1 ? "1 file" : $"{files} files")}",
+ StatusStyle.Type.Warning,
+ isStatic: true));
+
+ // Same header divider as the Fix-all cards, keeping every card's header/body split on one line — but no
+ // sweep: this header row is static, there is nothing to hover.
+ AddGroupDivider(card, withSweep: false);
+
+ // One resolver for the whole card: several violations commonly share one asset (e.g. a prefab with
+ // multiple unset required fields), and it memoises the object loads per asset path.
+ var labels = new ViolationFieldLabels();
+ foreach (var violation in violations)
+ card.AddChild(BuildRequiredViolationRow(violation, labels));
+
+ return card;
+ }
+
+ // Every card's header body: the title on the left, the count line on the right, ignored for picking so clicks
+ // fall through to the hosting button's own handler (a static row has none).
+ private static VisualElement BuildGroupHeaderRow(string title, string countText, StatusStyle.Type status, bool isStatic)
+ {
+ var header = new AspidLabel(title, AspidLabelPreset.Default
+ .SetLabelStatus(status)
+ .SetLabelSize(AspidLabelSizeStyle.Type.H5)
+ .SetLineSize(AspidDividingLineSizeStyle.Type.None))
+ .AddClass(GroupHeaderClass)
+ .SetPickingMode(PickingMode.Ignore);
+
+ var count = new Label(countText)
+ .AddClass(GroupCountClass)
+ .SetPickingMode(PickingMode.Ignore);
+
+ var row = new VisualElement()
+ .AddClass(GroupHeaderRowClass)
+ .AddChild(header)
+ .AddChild(count);
+ if (isStatic) row.AddClass(GroupHeaderRowStaticClass);
+ row.pickingMode = PickingMode.Ignore;
+
+ return row;
+ }
+
+ // Header divider plus, for an interactive header, its underline sweep (the Welcome cards' idiom). The sweep is
+ // the header's sibling, so USS :hover can't reach it; it rides the card's --header-hover modifier instead,
+ // which the ring lights. Both hide while the picker is docked — the dropdown is inserted right after the
+ // header, and they would land under it.
+ private static void AddGroupDivider(VisualElement card, bool withSweep, string sweepModifier = null)
+ {
+ card.AddChild(new AspidDividingLine(AspidDividingLinePreset.Default
+ .SetTheme(ThemeStyle.Type.Light)
+ .SetSize(AspidDividingLineSizeStyle.Type.Thin))
+ .AddClass(GroupDividerClass));
+
+ if (!withSweep) return;
+
+ var sweep = new VisualElement()
+ .AddClass(GroupSweepClass)
+ .SetPickingMode(PickingMode.Ignore);
+ if (sweepModifier is not null) sweep.AddClass(sweepModifier);
+ card.AddChild(sweep);
+ }
+
+ // Read-only entry row: clicking jumps to the asset — the bulk Fix above is the only mutation in project mode.
+ private VisualElement BuildGroupEntryRow(MissingReferenceLocation entry)
+ {
+ var path = MakeSelectable(new Label(entry.AssetPath).AddClass(GroupEntryPathClass));
+ path.tooltip = entry.AssetPath;
+
+ var rid = MakeSelectable(new Label($"rid {entry.Entry.Rid}").AddClass(GroupEntryRidClass));
+
+ return BuildEntryRow(entry.AssetPath, path, rid);
+ }
+
+ // Read-only entry row: asset path on the left, "Component.field" on the right; the whole row jumps to the
+ // asset — same cross-link as a broken-reference row.
+ private VisualElement BuildRequiredViolationRow(GateViolation violation, ViolationFieldLabels labels)
+ {
+ var path = MakeSelectable(new Label(violation.AssetPath).AddClass(GroupEntryPathClass));
+ path.tooltip = violation.AssetPath;
+
+ var field = MakeSelectable(new Label(labels.Describe(violation)).AddClass(GroupEntryFieldClass));
+
+ return BuildEntryRow(violation.AssetPath, path, field);
+ }
+
+ // The shared row shape behind both audit lists: two selectable columns over one click target.
+ private VisualElement BuildEntryRow(string assetPath, Label left, Label right)
+ {
+ var row = new VisualElement().AddClass(GroupEntryClass);
+ row.AddChild(left).AddChild(right);
+
+ row.RegisterCallback(evt =>
+ {
+ // A drag-select ends in a click too — don't navigate away from text the user is copying.
+ if (evt.target is TextElement text && text.selection.HasSelection()) return;
+ JumpToAsset(assetPath);
+ });
+
+ RegisterNavTarget(row, () => JumpToAsset(assetPath));
+ row.AddManipulator(new ContextualMenuManipulator(evt => PopulateEntryContextMenu(evt, assetPath)));
+
+ return row;
+ }
+
+ // Right-click alternatives to the row's default left-click jump. Runs after the selectable labels populate
+ // their own items (bubble-up), so the menu is wiped first to drop their Copy entry — Cmd+C on a selection
+ // still copies, and the menu stays the same three items wherever the click lands.
+ private void PopulateEntryContextMenu(ContextualMenuPopulateEvent evt, string assetPath)
+ {
+ for (var i = evt.menu.MenuItems().Count - 1; i >= 0; i--)
+ evt.menu.RemoveItemAt(i);
+
+ evt.menu.AppendAction("Open in Asset References", _ => JumpToAsset(assetPath));
+
+ evt.menu.AppendAction(
+ "Open in Prefab Mode",
+ _ => PrefabStageUtility.OpenPrefab(assetPath),
+ assetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)
+ ? DropdownMenuAction.Status.Normal
+ : DropdownMenuAction.Status.Disabled);
+
+ evt.menu.AppendAction("Select in Project", _ =>
+ {
+ var asset = AssetDatabase.LoadMainAssetAtPath(assetPath);
+ if (asset is null) return;
+
+ Selection.activeObject = asset;
+ EditorGUIUtility.PingObject(asset);
+ });
+ }
+
+ // Cross-link shared by every read-only audit row: jump to the asset's full Inspect graph; ping as a
+ // fallback when hosted standalone.
+ private void JumpToAsset(string assetPath)
+ {
+ var asset = AssetDatabase.LoadMainAssetAtPath(assetPath);
+ if (asset is null) return;
+
+ if (OnInspectAsset is not null) OnInspectAsset(asset);
+ else EditorGUIUtility.PingObject(asset);
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.Cards.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.Cards.cs.meta
new file mode 100644
index 00000000..e6e8b802
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.Cards.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 5b9aa65bf8fa4f838fa90a61b734d25e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.cs
new file mode 100644
index 00000000..6c95f7b4
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.cs
@@ -0,0 +1,406 @@
+using System;
+using System.Linq;
+using UnityEngine.UIElements;
+using Aspid.FastTools.UIElements;
+using System.Collections.Generic;
+using Aspid.FastTools.UIElements.Editors.Internal;
+using static Aspid.FastTools.SerializeReferences.Editors.SerializeReferenceAuditUI;
+using Object = UnityEngine.Object;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ ///
+ /// Project-wide repair tool for missing [SerializeReference] types. Scan Project sweeps every text
+ /// asset under Assets/ , groups the broken references by their stored (now unloadable) type, and offers a
+ /// single bulk Fix all per group: one type pick + one confirmation rewrites every entry across every
+ /// affected file. Unset [TypeSelector(Required = true)] fields are audited alongside them as a read-only
+ /// card that cross-links into the per-asset graph.
+ ///
+ ///
+ /// The implementation is split across partial files by concern: this file owns the chrome, the scan pass and the
+ /// results states; .Cards builds the group cards and their rows; .Actions runs the bulk fix, clear
+ /// and undo behind the inline picker. The copy lives in the pure
+ /// , the scan model in , and the
+ /// file edits in .
+ ///
+ internal sealed partial class SerializeReferenceProjectView : VisualElement
+ {
+ private const string StyleSheetPath = "UI/SerializeReferences/Aspid-FastTools-SerializeReference";
+
+ private const string RootClass = "aspid-fasttools-repair-references";
+ private const string ContentClass = RootClass + "__content";
+ private const string PanelClass = RootClass + "__panel";
+ private const string PanelTitleClass = RootClass + "__panel-title";
+ private const string PanelDescriptionClass = RootClass + "__panel-description";
+ private const string ScanProjectClass = RootClass + "__scan-project";
+ private const string EmptyClass = RootClass + "__empty";
+ private const string EmptyHiddenClass = EmptyClass + "--hidden";
+ private const string EmptyIconClass = RootClass + "__empty-icon";
+ private const string EmptyIconInfoClass = EmptyIconClass + "--info";
+ private const string EmptyIconSuccessClass = EmptyIconClass + "--success";
+ private const string EmptyTitleClass = RootClass + "__empty-title";
+ private const string EmptyMessageClass = RootClass + "__empty-message";
+ private const string ResultsClass = RootClass + "__results";
+ private const string ResultsHiddenClass = ResultsClass + "--hidden";
+ private const string ResultsHeaderClass = RootClass + "__results-header";
+ private const string ResultsHintClass = RootClass + "__results-hint";
+ private const string LegendClass = RootClass + "__legend";
+ private const string LegendHiddenClass = LegendClass + "--hidden";
+ private const string LegendItemClass = RootClass + "__legend-item";
+ private const string LegendDotClass = RootClass + "__legend-dot";
+ private const string LegendDotInfoClass = LegendDotClass + "--info";
+ private const string LegendTextClass = RootClass + "__legend-text";
+ private const string SummaryListClass = RootClass + "__summary-list";
+ private const string SummaryClass = RootClass + "__summary";
+ private const string SummaryUndoClass = RootClass + "__summary-undo";
+ private const string ScrollClass = RootClass + "__scroll";
+ private const string NavTargetClass = RootClass + "__nav-target";
+ private const string NavTargetFocusedClass = NavTargetClass + "--focused";
+
+ // Scan button label: cold call-to-action before the first scan, quiet refresh once the index is warm.
+ private const string ScanLabel = "Scan Project";
+ private const string RescanLabel = "Rescan";
+
+ private readonly VisualElement _empty;
+ private readonly VisualElement _results;
+ private readonly AspidLabel _resultsHeader;
+ private readonly VisualElement _summaries;
+ private readonly Label _resultsHint;
+ private readonly VisualElement _legend;
+ private readonly VisualElement _list;
+ private readonly AspidGradientButton _scanButton;
+ private readonly ScrollView _scroll;
+
+ // Keyboard navigation: one flat focus ring over every actionable element in visual order — Rescan first,
+ // then each card's Fix all / action row / entry rows — shared with the other window tabs.
+ private readonly NavRing _ring;
+
+ // The one inline picker, docked under whichever Fix all button opened it (see the .Actions partial).
+ private readonly AuditPickerHost _picker;
+
+ // The legend's block-specific USS class names for the shared item builder.
+ private static readonly LegendClasses _legendClassSet = new(LegendItemClass, LegendDotClass, LegendDotInfoClass, LegendTextClass);
+
+ // Required-violations audit has no incrementally-maintained index like SerializeReferenceTypeUsageIndex, so it
+ // is only (re)scanned on an explicit Scan/Rescan click, not on every Initialize() (tab switch would otherwise
+ // pay for a full project sweep). Static so the result survives the view being rebuilt on a tab switch.
+ private static bool _requiredIsWarm;
+ private static IReadOnlyList _requiredViolationsCache = Array.Empty();
+
+ private static IReadOnlyList RequiredViolationsForRender =>
+ _requiredIsWarm ? _requiredViolationsCache : Array.Empty();
+
+ ///
+ /// Jump from a project-audit result row to that asset's Inspect graph. Wired by the host window.
+ ///
+ public Action OnInspectAsset;
+
+ ///
+ /// Reports this view's state to the host window, which owns the shared dotted canvas and washes it with the
+ /// matching status. Wired by the window.
+ ///
+ public Action OnCanvasStatus;
+
+ public SerializeReferenceProjectView()
+ {
+ var root = this;
+ style.flexGrow = 1;
+ root.AddAspidThemeStyleSheets()
+ .AddStyleSheetsFromResource(StyleSheetPath)
+ .AddClass(RootClass);
+
+ var panelTitle = new AspidLabel("Find missing references", AspidLabelPreset.Default
+ .SetLabelTheme(ThemeStyle.Type.Lightness)
+ .SetLabelSize(AspidLabelSizeStyle.Type.H5)
+ .SetLineSize(AspidDividingLineSizeStyle.Type.None))
+ .AddClass(PanelTitleClass);
+
+ var panelDescription = new Label(
+ "Sweep every asset under Assets/ for broken [SerializeReference] types and bulk-fix them by type.")
+ .AddClass(PanelDescriptionClass);
+
+ // Label flips between ScanLabel and RescanLabel as the index warms.
+ _scanButton = new AspidGradientButton(ScanLabel, _ => ScanProject())
+ .AddClass(ScanProjectClass);
+
+ var panel = new VisualElement()
+ .AddClass(PanelClass)
+ .AddChild(panelTitle)
+ .AddChild(panelDescription)
+ .AddChild(_scanButton);
+
+ _empty = new VisualElement().AddClass(EmptyClass);
+
+ _resultsHeader = new AspidLabel(string.Empty, AspidLabelPreset.Default
+ .SetLabelStatus(StatusStyle.Type.Warning)
+ .SetLabelSize(AspidLabelSizeStyle.Type.H4)
+ .SetLineTheme(ThemeStyle.Type.Dark)
+ .SetLineStatus(StatusStyle.Type.Warning))
+ .AddClass(ResultsHeaderClass);
+
+ _resultsHint = new Label(string.Empty).AddClass(ResultsHintClass);
+
+ // Color key for the two card accents; only shown when both are actually on screen (see RenderGroups).
+ _legend = new VisualElement()
+ .AddClass(LegendClass)
+ .AddClass(LegendHiddenClass)
+ .AddChild(BuildLegendItem("Broken — pick a replacement", info: false, _legendClassSet))
+ .AddChild(BuildLegendItem("Renamed — one-click migrate", info: true, _legendClassSet));
+
+ // Receipt stack: one help-box per bulk Fix all, kept across chained fixes and cleared only on a fresh scan.
+ _summaries = new VisualElement().AddClass(SummaryListClass);
+
+ _list = new VisualElement();
+
+ _results = new VisualElement()
+ .AddClass(ResultsClass)
+ .AddChild(_resultsHeader)
+ .AddChild(_resultsHint)
+ .AddChild(_legend)
+ .AddChild(_summaries)
+ .AddChild(_list);
+
+ // One scroll spans the whole view, so the panel scrolls away with the group list instead of staying pinned.
+ var content = new VisualElement()
+ .AddClass(ContentClass)
+ .AddChild(panel)
+ .AddChild(_empty)
+ .AddChild(_results);
+
+ _scroll = new ScrollView().AddClass(ScrollClass);
+ _scroll.AddChild(content);
+
+ root.AddChild(_scroll);
+
+ _picker = new AuditPickerHost(this, _list, _pickerClassSet);
+
+ // The shared keyboard ring: the root holds focus (grabbed on attach, re-grabbed when the picker closes)
+ // so keys reach it before anything is highlighted. Suspended while a type picker owns the keyboard.
+ _ring = new NavRing(
+ host: this,
+ navTargetClass: NavTargetClass,
+ focusedClass: NavTargetFocusedClass,
+ scrollTo: element => _scroll.ScrollTo(element),
+ isSuspended: () => _picker.IsOpen);
+
+ ResetNavTargets();
+ }
+
+ // ---------------------------------------------------------------------------------------------------------
+ // Keyboard navigation
+ // ---------------------------------------------------------------------------------------------------------
+
+ // Every render pass rebuilds the ring from scratch (the old elements are gone with _list.Clear()). The Scan
+ // button outlives the render and re-registers here, so a highlight sitting on it comes back with it and
+ // Enter-on-Scan keeps its highlight.
+ private void ResetNavTargets()
+ {
+ _ring.Clear(keepFocusedElement: true);
+ RegisterNavTarget(_scanButton, ScanProject);
+ }
+
+ private void RegisterNavTarget(VisualElement element, Action activate) => _ring.Register(element, activate);
+
+ // ---------------------------------------------------------------------------------------------------------
+ // Scan pass
+ // ---------------------------------------------------------------------------------------------------------
+
+ ///
+ /// Restores whatever the warm index can already show, or opens idle when nothing has been scanned yet.
+ ///
+ ///
+ /// Cold index: wait for a deliberate Scan click — the cold sweep parses every asset's YAML behind a blocking
+ /// bar, so it must never run unasked. Warm index: re-deriving groups is a cheap in-memory filter, so results
+ /// survive a tab switch. The breakage-notification deep-link bypasses this and calls
+ /// directly.
+ ///
+ public void Initialize()
+ {
+ if (SerializeReferenceTypeUsageIndex.IsWarm || _requiredIsWarm) RenderWarmGroups();
+ else ShowIdle();
+ }
+
+ /// Sweeps the project for missing references and groups them by stored broken type.
+ /// Slow when the index is cold — this is the one deliberate moment the audit pays for a full sweep.
+ public void ScanProject()
+ {
+ if (_list is null) return;
+
+ _picker.Close();
+ ClearSummaries();
+
+ // Unlike the missing-type index, the required-field scan has nothing incremental behind it (see
+ // RequiredViolationsForRender).
+ _requiredViolationsCache = CollectRequiredViolations();
+ _requiredIsWarm = true;
+
+ RenderWarmGroups();
+ }
+
+ // Collects the unresolved set from the warm index and paints it; shared by Scan/Rescan and Initialize's warm restore.
+ private void RenderWarmGroups()
+ {
+ if (_list is null) return;
+ if (_scanButton is not null) _scanButton.Text = RescanLabel;
+
+ RenderGroups(MissingReferenceGroup.CollectFromIndex(), RequiredViolationsForRender);
+ }
+
+ // Full project sweep for unset [TypeSelector(Required = true)] fields, reusing the same headless scanner the
+ // build/CI gate uses. Skipped entirely when the gate is switched Off — a required audit nobody wants to fail
+ // or warn on shouldn't cost a full-project YAML sweep on every Scan click either.
+ private static IReadOnlyList CollectRequiredViolations() =>
+ SerializeReferenceSettings.BuildSeverity == GateSeverity.Off
+ ? Array.Empty()
+ : SerializeReferenceGateScanner.Scan(GateOptions.RequiredOnly);
+
+ // Paints a collected group set: count header + hint + one card per broken-type group plus one Required
+ // violations card, or the terminal hero when both are empty. The bulk actions special-case the came-back-clean
+ // case so their summary HelpBox survives (see ShowMissingReferencesClean).
+ private void RenderGroups(List groups, IReadOnlyList requiredViolations)
+ {
+ _list.Clear();
+ ResetNavTargets();
+
+ var missingCount = groups.Sum(group => group.Entries.Count);
+ var requiredCount = requiredViolations.Count;
+
+ if (missingCount == 0 && requiredCount == 0)
+ {
+ ShowEmptyState(
+ success: true,
+ title: "Project clean",
+ message: "No missing managed references or unset required fields found anywhere under Assets/.");
+ return;
+ }
+
+ // Pending migrations sink to the very bottom, below the Required violations card too: the whole amber
+ // band (broken groups, then required fields) stacks first and the calm blue one-click cards close the
+ // list. Each band keeps the scanner's order.
+ var migrations = new List<(MissingReferenceGroup Group, MissingReferenceMigration Migration)>();
+ foreach (var group in groups)
+ {
+ // Resolve constraint + migration ONCE per group and reuse it for the card and picker label below, so
+ // the partition and the card can never disagree on whether a group is a migration.
+ var migration = new MissingReferenceMigration(group);
+ if (migration.IsMigration) migrations.Add((group, migration));
+ else _list.AddChild(BuildGroupCard(group, migration));
+ }
+
+ // The header splits the migration entries out of the missing count — a [MovedFrom] rename with a
+ // one-click fix shouldn't inflate the alarm number.
+ var migrationCount = migrations.Sum(entry => entry.Group.Entries.Count);
+ ShowResults(
+ SerializeReferenceProjectSummary.BuildResultsHeaderText(missingCount - migrationCount, migrationCount, requiredCount),
+ StatusStyle.Type.Warning);
+ _resultsHint.text = SerializeReferenceProjectSummary.BuildResultsHintText(requiredCount > 0);
+
+ // The amber/blue key only earns its row when both accents are on screen at once.
+ var hasAmber = groups.Count > migrations.Count || requiredCount > 0;
+ _legend.EnableInClassList(LegendHiddenClass, migrations.Count == 0 || !hasAmber);
+
+ if (requiredCount > 0)
+ _list.AddChild(BuildRequiredGroupCard(requiredViolations));
+
+ foreach (var (group, migration) in migrations)
+ _list.AddChild(BuildGroupCard(group, migration));
+ }
+
+ // Re-derives the groups after a bulk edit and repaints. A group set that came back empty stays in the results
+ // region rather than the "Project clean" hero, which would hide the fix's summary receipt — the hero is
+ // reserved for an explicit Rescan.
+ private void RerenderAfterBulkEdit()
+ {
+ if (_scanButton is not null) _scanButton.Text = RescanLabel;
+
+ var groups = MissingReferenceGroup.CollectFromIndex();
+ if (groups.Count == 0) ShowMissingReferencesClean();
+ else RenderGroups(groups, RequiredViolationsForRender);
+ }
+
+ // ---------------------------------------------------------------------------------------------------------
+ // View states
+ // ---------------------------------------------------------------------------------------------------------
+
+ // Shared "no missing references left" branch for the bulk actions: stays in the results region (not the
+ // clean-state hero) so the fix's summary receipt survives, while still surfacing whatever Required violations
+ // card RequiredViolationsForRender currently reports (empty right after a clear-to-null, which invalidates the
+ // cache instead of risking a stale under-report — see ClearGroupToNull).
+ private void ShowMissingReferencesClean()
+ {
+ _list.Clear();
+ ResetNavTargets();
+ var requiredViolations = RequiredViolationsForRender;
+
+ ShowResults(
+ requiredViolations.Count == 0
+ ? "No missing references"
+ : $"No missing references, {BuildCountText(requiredViolations.Count, "required violation")}",
+ StatusStyle.Type.Success);
+ _resultsHint.text = "Nothing left to repair. Rescan to sweep the project again and confirm it's clean.";
+ _legend.AddClass(LegendHiddenClass);
+
+ if (requiredViolations.Count > 0)
+ _list.AddChild(BuildRequiredGroupCard(requiredViolations));
+ }
+
+ private void ShowEmptyState(bool success, string title, string message)
+ {
+ ResetNavTargets();
+ _results.AddClass(ResultsHiddenClass);
+ _empty.RemoveClass(EmptyHiddenClass);
+ _empty.Clear();
+ OnCanvasStatus?.Invoke(success ? StatusStyle.Type.Success : StatusStyle.Type.Info);
+
+ var icon = new VisualElement()
+ .AddClass(EmptyIconClass)
+ .AddClass(success ? EmptyIconSuccessClass : EmptyIconInfoClass);
+
+ var titlePreset = AspidLabelPreset.Default
+ .SetLabelTheme(success ? ThemeStyle.Type.Light : ThemeStyle.Type.Lightness)
+ .SetLabelSize(AspidLabelSizeStyle.Type.H3)
+ .SetLineSize(AspidDividingLineSizeStyle.Type.None);
+
+ if (success) titlePreset = titlePreset.SetLabelStatus(StatusStyle.Type.Success);
+
+ _empty.AddChild(icon)
+ .AddChild(new AspidLabel(title, titlePreset).AddClass(EmptyTitleClass))
+ .AddChild(new Label(message).AddClass(EmptyMessageClass));
+ }
+
+ // Cold-index idle state until the first scan. No results list yet — the project is unscanned, so "clean"
+ // cannot be claimed.
+ private void ShowIdle() => ShowEmptyState(
+ success: false,
+ title: "Project not scanned",
+ message: "Run Scan Project to map every broken [SerializeReference] type across your assets — then repair each missing type in bulk.");
+
+ // The status is explicit per call site: the missing-references sweep washes Warning, while the came-back-clean
+ // receipt washes Success rather than leaving a clean state on an amber backdrop.
+ private void ShowResults(string headerText, StatusStyle.Type status)
+ {
+ _empty.AddClass(EmptyHiddenClass);
+ _results.RemoveClass(ResultsHiddenClass);
+ _resultsHeader.Text = headerText;
+ OnCanvasStatus?.Invoke(status);
+ }
+
+ // Appends one receipt to the running stack (newest at the bottom) rather than overwriting the previous; only
+ // ClearSummaries resets it on the next fresh scan. The Undo button reverts exactly this fix.
+ private void ShowSummary(string title, string message, Action onUndo)
+ {
+ var summary = new AspidHelpBox(AspidHelpBoxPreset.Default.SetMessageType(HelpBoxMessageType.Warning))
+ .AddClass(SummaryClass);
+ summary.Title = title;
+ summary.Message = message;
+
+ if (onUndo is not null)
+ summary.AddChild(new AspidGradientButton("Undo", _ => onUndo(summary)).AddClass(SummaryUndoClass));
+
+ _summaries.AddChild(summary);
+ }
+
+ private void ClearSummaries() => _summaries?.Clear();
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceProjectView.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.cs.meta
similarity index 100%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceProjectView.cs.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Project/SerializeReferenceProjectView.cs.meta
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared.meta
new file mode 100644
index 00000000..29d68b4e
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 25c6f10abafe44a69417e749c2c5af7e
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/AuditPickerHost.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/AuditPickerHost.cs
new file mode 100644
index 00000000..043b4f5d
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/AuditPickerHost.cs
@@ -0,0 +1,119 @@
+using UnityEngine.UIElements;
+using Aspid.FastTools.UIElements;
+using Aspid.FastTools.Types.Editors;
+using Aspid.FastTools.UIElements.Editors.Internal;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ ///
+ /// The inline type picker both References tabs dock under a clicked card header: one panel open at a time, dropped
+ /// directly below its anchor inside the anchor's own card so the header, selector and the rows beneath it read as
+ /// one active card.
+ ///
+ ///
+ /// The two tabs keep their own USS blocks, so the host wears the block's class names passed as a
+ /// — the same arrangement uses
+ /// for the legend. Anchors are expected to end in a chevron, which the host swaps in place (▼ ⇄ ▲) rather than
+ /// rewriting the label, so every band verb keeps its own wording.
+ ///
+ internal sealed class AuditPickerHost
+ {
+ /// The block-specific USS class names the shared picker host wears.
+ internal readonly struct PickerClasses
+ {
+ /// The docked panel itself.
+ public readonly string Picker;
+
+ /// Welds the panel to the header above it; applied only when the anchor sits inside a card.
+ public readonly string PickerAttached;
+
+ /// Marks the hosting card as picking, so its divider / hover sweep stand down.
+ public readonly string CardPicking;
+
+ public PickerClasses(string picker, string pickerAttached, string cardPicking)
+ {
+ Picker = picker;
+ PickerAttached = pickerAttached;
+ CardPicking = cardPicking;
+ }
+ }
+
+ private const char ChevronCollapsed = '▼';
+ private const char ChevronExpanded = '▲';
+
+ private readonly VisualElement _host;
+ private readonly VisualElement _fallbackContainer;
+ private readonly PickerClasses _classes;
+
+ private VisualElement _picker;
+ private AspidGradientButton _anchor;
+ private VisualElement _card;
+
+ /// The view itself — it reclaims keyboard focus when the picker closes.
+ /// Where the panel lands if an anchor is ever hosted outside a card.
+ /// The hosting block's picker class names.
+ public AuditPickerHost(VisualElement host, VisualElement fallbackContainer, in PickerClasses classes)
+ {
+ _host = host;
+ _fallbackContainer = fallbackContainer;
+ _classes = classes;
+ }
+
+ /// Whether a picker is currently docked — the views suspend their keyboard ring while it is.
+ public bool IsOpen => _picker is not null;
+
+ ///
+ /// The close half of a toggle: closes whatever is open and reports whether that was
+ /// 's own picker, i.e. whether the click was a collapse and the caller should stop.
+ ///
+ public bool ToggleClosed(AspidGradientButton anchor)
+ {
+ var wasOpen = _anchor == anchor;
+ Close();
+ return wasOpen;
+ }
+
+ /// Docks directly below and focuses it.
+ public void Open(AspidGradientButton anchor, TypeSelectorView content)
+ {
+ _picker = new AspidBox(AspidBoxPreset.Default.SetTheme(ThemeStyle.Type.Darkness))
+ .AddClass(_classes.Picker)
+ .AddChild(content);
+
+ _anchor = anchor;
+ if (anchor is not null) anchor.Text = anchor.Text.Replace(ChevronCollapsed, ChevronExpanded);
+
+ // The anchor is a direct child of its card, so the panel drops right below it inside the card; the ??
+ // fallback keeps a sane target if the anchor is ever hosted outside one.
+ var card = anchor?.parent;
+ var container = card ?? _fallbackContainer;
+ container.InsertChild(container.IndexOf(anchor) + 1, _picker);
+
+ if (card is not null)
+ {
+ _card = card;
+ _card.AddClass(_classes.CardPicking);
+ _picker.AddClass(_classes.PickerAttached);
+ }
+
+ content.FocusPicker();
+ }
+
+ /// Undocks the panel, restores its anchor's chevron and hands keyboard focus back to the host.
+ public void Close()
+ {
+ _picker?.RemoveFromHierarchy();
+ if (_anchor is not null) _anchor.Text = _anchor.Text.Replace(ChevronExpanded, ChevronCollapsed);
+ _card?.RemoveClass(_classes.CardPicking);
+
+ _picker = null;
+ _anchor = null;
+ _card = null;
+
+ // The dismissed picker leaves keyboard focus dangling on its (removed) search field; reclaim it so the
+ // arrow-key ring keeps working. Guarded — Close also runs from render paths before attach.
+ if (_host.panel is not null) _host.Focus();
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/AuditPickerHost.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/AuditPickerHost.cs.meta
new file mode 100644
index 00000000..c57fc063
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/AuditPickerHost.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c1cdb8081e8d42549efd6f3043607c81
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/ManagedReferenceFilter.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/ManagedReferenceFilter.cs
new file mode 100644
index 00000000..f30c77df
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/ManagedReferenceFilter.cs
@@ -0,0 +1,31 @@
+using System;
+using Aspid.FastTools.Types.Editors;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ ///
+ /// The candidate filter every managed-reference picker in the References window shares, so an inline card fix and
+ /// a bulk group fix can never offer different type sets for the same constraint.
+ ///
+ internal static class ManagedReferenceFilter
+ {
+ ///
+ /// Concrete types assignable to , plus the open generic definitions that can
+ /// close over it. A or constraint falls back to unconstrained
+ /// (any managed-reference type).
+ ///
+ public static TypeSelectorFilter For(Type constraint)
+ {
+ var baseType = constraint ?? typeof(object);
+
+ return new TypeSelectorFilter
+ {
+ Types = new[] { baseType },
+ Predicate = SerializeReferenceHelpers.IsAssignableManagedReference,
+ AdditionalTypes = baseType == typeof(object) ? null : GenericTypeResolver.GetAssignableGenericDefinitions(baseType),
+ ArgumentFilter = SerializeReferenceHelpers.IsValidGenericArgument,
+ };
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/ManagedReferenceFilter.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/ManagedReferenceFilter.cs.meta
new file mode 100644
index 00000000..38f8cee5
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/ManagedReferenceFilter.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ff66f50f2971424fb672e7e3a3572fb0
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceAuditUI.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/SerializeReferenceAuditUI.cs
similarity index 73%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceAuditUI.cs
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/SerializeReferenceAuditUI.cs
index 07cedb7e..80a043e0 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceAuditUI.cs
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/SerializeReferenceAuditUI.cs
@@ -1,5 +1,6 @@
using UnityEngine.UIElements;
using Aspid.FastTools.UIElements;
+using Aspid.FastTools.UIElements.Editors.Internal;
// ReSharper disable once CheckNamespace
namespace Aspid.FastTools.SerializeReferences.Editors
@@ -19,6 +20,19 @@ internal static class SerializeReferenceAuditUI
public static string BuildCountText(int count, string noun) =>
count == 1 ? $"1 {noun}" : $"{count} {(noun.EndsWith("y") ? noun[..^1] + "ies" : noun + "s")}";
+ ///
+ /// The audit's shared severity verdict, driving both a headline's own tint and the window canvas wash behind
+ /// it: anything broken, orphaned or required-unset is amber; a graph whose only findings are pending
+ /// [MovedFrom] migrations is info-blue (a stale file is not a breakage); otherwise green.
+ ///
+ /// Missing references, EXCLUDING the pending migrations counted separately.
+ public static StatusStyle.Type ResolveStatus(int broken, int orphans, int required, int migrations) =>
+ broken > 0 || orphans > 0 || required > 0
+ ? StatusStyle.Type.Warning
+ : migrations > 0
+ ? StatusStyle.Type.Info
+ : StatusStyle.Type.Success;
+
///
/// Makes an audit row's text (asset paths, rids, field paths) selectable so it can be copied out; callers that
/// also carry a row click gate the click on an empty selection (a drag-select ends in a click too).
@@ -32,7 +46,7 @@ public static Label MakeSelectable(Label label)
}
///
- /// One dot + caption pair of the accent legend: amber (default) for the broken/orphaned/required band, info
+ /// One dot + caption a pair of the accent legend: amber (default) for the broken/orphaned/required band, info
/// blue ( true) for the pending-migration cards, wearing the block's own legend classes.
///
public static VisualElement BuildLegendItem(string text, bool info, in LegendClasses classes)
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceAuditUI.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/SerializeReferenceAuditUI.cs.meta
similarity index 100%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceAuditUI.cs.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/SerializeReferenceAuditUI.cs.meta
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/ViolationFieldLabels.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/ViolationFieldLabels.cs
new file mode 100644
index 00000000..0abe1747
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/ViolationFieldLabels.cs
@@ -0,0 +1,52 @@
+using System;
+using UnityEditor;
+using System.Collections.Generic;
+using Object = UnityEngine.Object;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ ///
+ /// Names the owning object of a required-field violation for display — "Component.field" , or the field path
+ /// alone when the owner cannot be identified. Shared by both References tabs so their required rows read alike.
+ ///
+ ///
+ /// A carries only the asset path and file id, so the owner is resolved on demand by
+ /// object-loading the asset and matching the id — the same lookup the gate scanner does internally to build the
+ /// violation, just for display here. Best-effort: scenes cannot be object-loaded (see
+ /// ), so a scene row shows the field path rather than guessing.
+ /// Loads are memoised per asset path, since several violations commonly share one asset.
+ ///
+ internal sealed class ViolationFieldLabels
+ {
+ private readonly Dictionary _assets = new(StringComparer.Ordinal);
+
+ /// The violation's "Component.field" label, or its field path alone.
+ public string Describe(GateViolation violation)
+ {
+ var component = ResolveComponentName(violation);
+ return string.IsNullOrEmpty(component) ? violation.FieldPath : $"{component}.{violation.FieldPath}";
+ }
+
+ /// The violation's owning object type name, or an empty string when it cannot be identified.
+ public string ResolveComponentName(GateViolation violation)
+ {
+ if (SerializeReferenceHelpers.IsScene(violation.AssetPath)) return string.Empty;
+
+ if (!_assets.TryGetValue(violation.AssetPath, out var assets))
+ {
+ assets = AssetDatabase.LoadAllAssetsAtPath(violation.AssetPath);
+ _assets[violation.AssetPath] = assets;
+ }
+
+ foreach (var asset in assets)
+ {
+ if (asset == null) continue;
+ if (AssetDatabase.TryGetGUIDAndLocalFileIdentifier(asset, out _, out var fileId) && fileId == violation.FileId)
+ return asset.GetType().Name;
+ }
+
+ return string.Empty;
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/ViolationFieldLabels.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/ViolationFieldLabels.cs.meta
new file mode 100644
index 00000000..08d3ae00
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/References/Shared/ViolationFieldLabels.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 7815f40485b944ed9809059184345a31
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Settings.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Settings.meta
new file mode 100644
index 00000000..4b688e7a
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Settings.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 6629c7f5757514b5faccf7238aa50e96
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SettingsView.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Settings/SettingsView.cs
similarity index 92%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SettingsView.cs
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Settings/SettingsView.cs
index f308023e..cce11871 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SettingsView.cs
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Settings/SettingsView.cs
@@ -1,6 +1,5 @@
using System;
using UnityEngine;
-using UnityEditor.UIElements;
using UnityEngine.UIElements;
using Aspid.FastTools.Editors;
using Aspid.FastTools.UIElements.Editors.Internal;
@@ -47,7 +46,7 @@ public SettingsView()
_ring = new NavRing(
host: this,
navTargetClass: AspidSettingsUI.NavTargetClass,
- paint: (element, on) => element.EnableInClassList(AspidSettingsUI.NavTargetFocusedClass, on),
+ focusedClass: AspidSettingsUI.NavTargetFocusedClass,
scrollTo: element => { if (IsInScrollContent(element)) _scroll.ScrollTo(element); });
// The surface's controls are stable for the tab's lifetime (the excluded-folders panel rebuilds only its
@@ -62,8 +61,10 @@ public SettingsView()
private bool IsInScrollContent(VisualElement element)
{
for (var parent = element.parent; parent != null; parent = parent.parent)
+ {
if (parent == _scroll.contentContainer)
return true;
+ }
return false;
}
@@ -111,18 +112,11 @@ private void CollectNavTargets(VisualElement element)
CollectNavTargets(child);
}
- // Re-collects the whole ring after the excluded-folders rows are replaced. A highlight is restored to the
- // same position, clamped — so deleting a folder row with the keyboard lands the highlight on the next row
+ // Re-collects the whole ring after the excluded-folders rows are replaced. Rebuild restores the highlight to
+ // the same position, clamped — so deleting a folder row with the keyboard lands the highlight on the next row
// instead of dropping it.
- private void RebuildNavTargets()
- {
- var restore = _ring.Index;
- _ring.Clear();
- CollectNavTargets(this);
-
- if (restore >= 0 && _ring.Count > 0)
- _ring.Focus(Mathf.Min(restore, _ring.Count - 1));
- }
+ private void RebuildNavTargets() =>
+ _ring.Rebuild(() => CollectNavTargets(this));
// Enter on the gate dropdown steps to the next value (wrapping) instead of opening the popup — the popup is
// a native menu the ring can't reach into, while cycling keeps the whole interaction on the keyboard.
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SettingsView.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Settings/SettingsView.cs.meta
similarity index 100%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SettingsView.cs.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Settings/SettingsView.cs.meta
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabType.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabType.cs
new file mode 100644
index 00000000..c5f54e17
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabType.cs
@@ -0,0 +1,10 @@
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ internal enum TabType
+ {
+ Welcome,
+ AssetReference,
+ ProjectReferences,
+ Settings,
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabType.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabType.cs.meta
new file mode 100644
index 00000000..1eb6919b
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabType.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 2bbfc719e7524cd7a8597781846f98e5
+timeCreated: 1785676844
\ No newline at end of file
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabWindow.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabWindow.cs
new file mode 100644
index 00000000..82666213
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabWindow.cs
@@ -0,0 +1,247 @@
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UIElements;
+using Aspid.FastTools.Editors;
+using Aspid.FastTools.UIElements;
+using Aspid.FastTools.UIElements.Editors.Internal;
+using Object = UnityEngine.Object;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ ///
+ /// The single managed-reference workbench. Two modes share one window: Asset References maps a saved asset's
+ /// whole reference graph and repairs entries inline, and Project References sweeps the project for missing
+ /// references and bulk-fixes them grouped by broken type. The per-asset repair list of the old Repair window is
+ /// subsumed by the richer Inspect graph; the project sweep keeps its grouped bulk-fix flow.
+ ///
+ internal sealed class TabWindow : EditorWindow
+ {
+ private const string RootClass = "aspid-fasttools-serialize-reference-window";
+ private const string BackgroundClass = RootClass + "__background";
+ private const string ToolbarClass = RootClass + "__toolbar";
+ private const string ToolbarButtonClass = RootClass + "__toolbar-button";
+ private const string ToolbarButtonActiveClass = ToolbarButtonClass + "--active";
+ private const string ToolbarButtonSquareClass = ToolbarButtonClass + "--square";
+ private const string TabUnderlineClass = RootClass + "__tab-underline";
+ private const string TabHintClass = RootClass + "__tab-hint";
+ private const string TabIconClass = RootClass + "__tab-icon";
+ private const string TabIconHomeClass = TabIconClass + "--home";
+ private const string TabIconSettingsClass = TabIconClass + "--settings";
+ private const string ContainerClass = RootClass + "__container";
+
+ private const string WindowStyleSheetPath = "UI/SerializeReferences/Aspid-FastTools-SerializeReference-Window";
+
+ // The Aspid brand mark shown beside the window title; padded variant so it doesn't dominate the tab.
+ private const string WindowIconPath = "Icons/aspid_icon_window_tab_green_1022x1011";
+
+ // Below this the toolbar tabs and cards degrade into slivers; applied in CreateGUI so every instance
+ // gets it — including panes restored from a saved layout, which never pass through Reveal.
+ private static readonly Vector2 _minWindowSize = new(480f, 360f);
+
+ private AspidAnimatedDotsBackground _background;
+ private VisualElement _container;
+ private Button _homeButton;
+ private Button _inspectButton;
+ private Button _projectButton;
+ private Button _settingsButton;
+
+ [SerializeField] private Object _pendingTarget;
+
+ // One-shot flag: the breakage-notification deep-link wants the project scanned immediately even from a cold
+ // index, whereas a plain Project References tab click is warmth-gated inside the view. Consumed in SwitchMode.
+ private bool _forceProjectScan;
+
+ internal TabType CurrentTabType { get; private set; }
+
+ #region Open Methods
+ [MenuItem("Tools/Aspid 🐍/FastTools/Welcome", priority = 0)]
+ public static void OpenWelcome()
+ {
+ var window = Open();
+ window.SwitchMode(TabType.Welcome);
+
+ WelcomeWindowStartup.MarkSeen();
+ }
+
+ [MenuItem("Tools/Aspid 🐍/FastTools/Asset References", priority = 20)]
+ public static void OpenAssetReferences() =>
+ OpenAssetReferences(Selection.activeObject);
+
+ public static void OpenAssetReferences(Object target)
+ {
+ var window = Open();
+
+ window._pendingTarget = target;
+ window.SwitchMode(TabType.AssetReference);
+ }
+
+ [MenuItem("Tools/Aspid 🐍/FastTools/Project References", priority = 21)]
+ public static void OpenProjectReferences() =>
+ Open().SwitchMode(TabType.ProjectReferences);
+
+ [MenuItem("Tools/Aspid 🐍/FastTools/Settings", priority = 40)]
+ public static void OpenSettings() =>
+ Open().SwitchMode(TabType.Settings);
+
+ private static TabWindow Open()
+ {
+ var window = GetWindow();
+ window.Show();
+
+ return window;
+ }
+ #endregion
+
+ private void CreateGUI()
+ {
+ minSize = _minWindowSize;
+ titleContent = new GUIContent("Aspid FastTools", Resources.Load(WindowIconPath));
+
+ var root = rootVisualElement;
+ root.AddAspidThemeStyleSheets()
+ .AddStyleSheetsFromResource(WindowStyleSheetPath)
+ .AddClass(RootClass);
+
+ // One dotted canvas, owned by the window, fills it behind everything; its tint follows the active view's
+ // state via the SetCanvasStatus callback handed to each view.
+ _background = new AspidAnimatedDotsBackground()
+ .AddClass(BackgroundClass)
+ .SetPickingMode(PickingMode.Ignore);
+
+ _homeButton = SquareTabButton(TabType.Welcome, TabIconHomeClass);
+ _inspectButton = ModeButton("Asset References", TabType.AssetReference);
+ _projectButton = ModeButton("Project References", TabType.ProjectReferences);
+ _settingsButton = SquareTabButton(TabType.Settings, TabIconSettingsClass);
+
+ var toolbar = new VisualElement().AddClass(ToolbarClass);
+ toolbar.AddChild(_homeButton)
+ .AddChild(_inspectButton)
+ .AddChild(_projectButton)
+ .AddChild(_settingsButton);
+
+ _container = new VisualElement().AddClass(ContainerClass);
+ _container.style.flexGrow = 1;
+
+ // The footer is owned by the window, not any single tab, so it stays pinned to the bottom across every
+ // mode; _container (flex-grow:1) pushes it down.
+ root.AddChild(_background)
+ .AddChild(toolbar)
+ .AddChild(_container)
+ .AddChild(new AspidWindowFooter());
+
+ SwitchMode(CurrentTabType);
+ }
+
+ private Button ModeButton(string label, TabType tabType)
+ {
+ var hint = TabWindowShortcuts.HintFor(tabType);
+
+ var button = new Button(() => SwitchMode(tabType)) { text = label, tooltip = hint };
+ button.AddClass(ToolbarButtonClass);
+
+ // Shortcut badge, absolutely positioned so it floats over the button without disturbing the centred label.
+ button.AddChild(new Label(hint)
+ .AddClass(TabHintClass)
+ .SetPickingMode(PickingMode.Ignore));
+
+ // The active underline is a child bar, not a border-bottom — flipping a child's background-color via the
+ // parent's --active class repaints reliably (a border-color flip only showed up after a window resize).
+ button.AddChild(new VisualElement()
+ .AddClass(TabUnderlineClass)
+ .SetPickingMode(PickingMode.Ignore));
+
+ return button;
+ }
+
+ // The edge tabs (home / settings) are square and icon-only: the USS --square modifier overrides the flex
+ // sizing, the inner __tab-icon modifier supplies the glyph. Same underline bar as the mode tabs.
+ private Button SquareTabButton(TabType tabType, string iconModifierClass)
+ {
+ var button = new Button(() => SwitchMode(tabType)) { tooltip = TabWindowShortcuts.HintFor(tabType) };
+ button.AddClass(ToolbarButtonClass).AddClass(ToolbarButtonSquareClass);
+
+ button.AddChild(new VisualElement()
+ .AddClass(TabIconClass)
+ .AddClass(iconModifierClass)
+ .SetPickingMode(PickingMode.Ignore));
+
+ button.AddChild(new VisualElement()
+ .AddClass(TabUnderlineClass)
+ .SetPickingMode(PickingMode.Ignore));
+
+ return button;
+ }
+
+ internal void SwitchMode(TabType tabType)
+ {
+ CurrentTabType = tabType;
+ if (_container is null) return; // Open() ran before CreateGUI; CreateGUI re-invokes SwitchMode(_mode).
+
+ _container.Clear();
+
+ if (tabType == TabType.Welcome)
+ {
+ // Welcome carries no single status; dropping the status class restores the default signal gradient a
+ // prior view's wash flattened.
+ SetCanvasStatus(StatusStyle.Type.None);
+ _container.AddChild(new WelcomeView());
+ }
+ else if (tabType == TabType.AssetReference)
+ {
+ // Track the in-view pick back onto _pendingTarget so a tab switch rebuilds the view on the asset the user
+ // actually has open, not the one Inspect first opened on.
+ _container.AddChild(new SerializeReferenceGraphView(_pendingTarget, SetCanvasStatus, target => _pendingTarget = target));
+ }
+ else if (tabType == TabType.Settings)
+ {
+ // Settings carries no status either; the calm idle wash keeps the canvas neutral here.
+ SetCanvasStatus(StatusStyle.Type.Info);
+ _container.AddChild(new SettingsView());
+ }
+ else
+ {
+ var project = new SerializeReferenceProjectView
+ {
+ OnInspectAsset = InspectAsset,
+ OnCanvasStatus = SetCanvasStatus,
+ };
+ _container.AddChild(project);
+
+ // A plain tab switch never auto-scans (no scan freeze on large projects); only the
+ // breakage-notification deep-link forces the scan.
+ if (_forceProjectScan)
+ {
+ _forceProjectScan = false;
+ project.ScanProject();
+ }
+ else
+ {
+ project.Initialize();
+ }
+ }
+
+ UpdateToolbar();
+ }
+
+ // The active view reports its state here; the window owns the shared dotted canvas and applies it as a status
+ // class, so the wash itself stays in the canvas stylesheet rather than in any view.
+ private void SetCanvasStatus(StatusStyle.Type status) =>
+ _background?.SetStatus(status);
+
+ // Cross-link: jumping from a project-audit result to that asset's full graph.
+ private void InspectAsset(Object target)
+ {
+ _pendingTarget = target;
+ SwitchMode(TabType.AssetReference);
+ }
+
+ private void UpdateToolbar()
+ {
+ _homeButton?.EnableInClassList(ToolbarButtonActiveClass, CurrentTabType == TabType.Welcome);
+ _inspectButton?.EnableInClassList(ToolbarButtonActiveClass, CurrentTabType == TabType.AssetReference);
+ _projectButton?.EnableInClassList(ToolbarButtonActiveClass, CurrentTabType == TabType.ProjectReferences);
+ _settingsButton?.EnableInClassList(ToolbarButtonActiveClass, CurrentTabType == TabType.Settings);
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceWindow.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabWindow.cs.meta
similarity index 100%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Windows/SerializeReferenceWindow.cs.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabWindow.cs.meta
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabWindowShortcuts.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabWindowShortcuts.cs
new file mode 100644
index 00000000..bc458e53
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabWindowShortcuts.cs
@@ -0,0 +1,163 @@
+using System;
+using UnityEngine;
+using UnityEditor.ShortcutManagement;
+
+namespace Aspid.FastTools.SerializeReferences.Editors
+{
+ ///
+ /// The single owner of the tab keyboard layout: shortcut ids, their default bindings, and the order Ctrl+Tab
+ /// cycles through.
+ ///
+ ///
+ /// The window renders its badges from rather than rebuilding the defaults itself, so a key
+ /// changed here can never disagree with what the toolbar shows.
+ ///
+ internal static class TabWindowShortcuts
+ {
+ private const string Category = "Aspid FastTools/Window/";
+
+ private const string NextTabId = Category + "Next Tab";
+ private const string PreviousTabId = Category + "Previous Tab";
+
+ private const string HomeId = Category + "Home";
+ private const string AssetReferencesId = Category + "Asset References";
+ private const string ProjectReferencesId = Category + "Project References";
+ private const string SettingsId = Category + "Settings";
+
+ private const KeyCode HomeKey = KeyCode.Alpha1;
+ private const KeyCode SettingsKey = KeyCode.Alpha0;
+ private const KeyCode AssetReferencesKey = KeyCode.Alpha2;
+ private const KeyCode ProjectReferencesKey = KeyCode.Alpha3;
+ private const ShortcutModifiers TabModifiers = ShortcutModifiers.Alt;
+
+ // Tab order for cycling and the lookup behind HintFor, ordered as the toolbar renders them. Cycling walks this
+ // array instead of the TabType values, so reordering the enum can't silently reshuffle Ctrl+Tab.
+ private static readonly TabData[] _tabData =
+ {
+ new(HomeId, TabType.Welcome, HomeKey),
+ new(AssetReferencesId, TabType.AssetReference, AssetReferencesKey),
+ new(ProjectReferencesId, TabType.ProjectReferences, ProjectReferencesKey),
+ new(SettingsId, TabType.Settings, SettingsKey),
+ };
+
+ [Shortcut(HomeId, typeof(TabWindow), HomeKey, TabModifiers)]
+ private static void OnHomeShortcut(ShortcutArguments args) =>
+ SwitchFrom(args, TabType.Welcome);
+
+ [Shortcut(AssetReferencesId, typeof(TabWindow), AssetReferencesKey, TabModifiers)]
+ private static void OnInspectShortcut(ShortcutArguments args) =>
+ SwitchFrom(args, TabType.AssetReference);
+
+ [Shortcut(ProjectReferencesId, typeof(TabWindow), ProjectReferencesKey, TabModifiers)]
+ private static void OnProjectShortcut(ShortcutArguments args) =>
+ SwitchFrom(args, TabType.ProjectReferences);
+
+ [Shortcut(SettingsId, typeof(TabWindow), SettingsKey, TabModifiers)]
+ private static void OnSettingsShortcut(ShortcutArguments args) =>
+ SwitchFrom(args, TabType.Settings);
+
+ [Shortcut(NextTabId, typeof(TabWindow), KeyCode.Tab, ShortcutModifiers.Control)]
+ private static void OnNextTabShortcut(ShortcutArguments args) =>
+ CycleFrom(args, +1);
+
+ [Shortcut(PreviousTabId, typeof(TabWindow), KeyCode.Tab, ShortcutModifiers.Control | ShortcutModifiers.Shift)]
+ private static void OnPreviousTabShortcut(ShortcutArguments args) =>
+ CycleFrom(args, -1);
+
+ ///
+ /// The badge and tooltip text for a tab: the live binding read from the ShortcutManager, so it tracks user
+ /// rebinds and renders the real per-platform glyph.
+ ///
+ ///
+ /// Falls back to the shortcut's declared default when the id isn't registered yet or its binding was cleared.
+ ///
+ internal static string HintFor(TabType tab)
+ {
+ foreach (var tabData in _tabData)
+ {
+ if (tabData.Tab != tab) continue;
+ return LiveBinding(tabData.Id) ?? DefaultHint(tabData.Key);
+ }
+
+ return string.Empty;
+ }
+
+ private static void SwitchFrom(ShortcutArguments args, TabType tab)
+ {
+ if (args.context is TabWindow window)
+ window.SwitchMode(tab);
+ }
+
+ private static void CycleFrom(ShortcutArguments args, int step)
+ {
+ if (args.context is not TabWindow window) return;
+
+ var currentTabIndex = IndexOf(window.CurrentTabType);
+ var nextTabIndex = (currentTabIndex + step + _tabData.Length) % _tabData.Length;
+
+ window.SwitchMode(_tabData[nextTabIndex].Tab);
+ }
+
+ private static int IndexOf(TabType tab)
+ {
+ for (var i = 0; i < _tabData.Length; i++)
+ {
+ if (_tabData[i].Tab == tab) return i;
+ }
+
+ return 0;
+ }
+
+ private static string LiveBinding(string shortcutId)
+ {
+ try
+ {
+ var binding = ShortcutManager.instance.GetShortcutBinding(shortcutId).ToString();
+ return string.IsNullOrEmpty(binding) ? null : binding;
+ }
+ catch (Exception)
+ {
+ // ShortcutManager not ready / unknown id — the caller falls back to the declared default.
+ return null;
+ }
+ }
+
+ // LiveBinding's fallback, spelled from the [Shortcut] defaults above: glyphs on macOS, spelled-out names
+ // elsewhere, mirroring how Unity itself renders a binding.
+ private static string DefaultHint(KeyCode key)
+ {
+ var label = key is >= KeyCode.Alpha0 and <= KeyCode.Alpha9
+ ? (key - KeyCode.Alpha0).ToString()
+ : key.ToString();
+
+ return ModifierPrefix(TabModifiers) + label;
+ }
+
+ private static string ModifierPrefix(ShortcutModifiers modifiers)
+ {
+ var isMac = Application.platform == RuntimePlatform.OSXEditor;
+ var prefix = string.Empty;
+
+ if ((modifiers & ShortcutModifiers.Control) != 0) prefix += isMac ? "⌃" : "Ctrl+";
+ if ((modifiers & ShortcutModifiers.Action) != 0) prefix += isMac ? "⌘" : "Ctrl+";
+ if ((modifiers & ShortcutModifiers.Alt) != 0) prefix += isMac ? "⌥" : "Alt+";
+ if ((modifiers & ShortcutModifiers.Shift) != 0) prefix += isMac ? "⇧" : "Shift+";
+
+ return prefix;
+ }
+
+ private readonly struct TabData
+ {
+ internal readonly string Id;
+ internal readonly TabType Tab;
+ internal readonly KeyCode Key;
+
+ internal TabData(string id, TabType tab, KeyCode key)
+ {
+ Id = id;
+ Tab = tab;
+ Key = key;
+ }
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabWindowShortcuts.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabWindowShortcuts.cs.meta
new file mode 100644
index 00000000..79679838
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/TabWindowShortcuts.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: bb9f1b6e224846a3a84163b57a6ac77d
+timeCreated: 1785338278
\ No newline at end of file
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome.meta
similarity index 100%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome.meta
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeSettings.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeSettings.cs
similarity index 97%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeSettings.cs
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeSettings.cs
index b9e422b6..0b1f2446 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeSettings.cs
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeSettings.cs
@@ -24,7 +24,7 @@ internal static class WelcomeSettings
private static string AutoShowKey => AutoShowKeyPrefix + PlayerSettings.productGUID;
///
- /// Whether the Welcome tab may auto-open on the first launch after an install or a package update (the
+ /// Whether the Welcome tab may auto-open on the first launch after an installation or a package update (the
/// once-per-version gate itself lives in ). Off suppresses every future
/// auto-show; the manual menu entry keeps working either way.
///
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeSettings.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeSettings.cs.meta
similarity index 100%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeSettings.cs.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeSettings.cs.meta
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeSettingsUI.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeSettingsUI.cs
similarity index 98%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeSettingsUI.cs
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeSettingsUI.cs
index ed374f83..61ec86ad 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeSettingsUI.cs
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeSettingsUI.cs
@@ -1,6 +1,5 @@
using System;
using UnityEngine.UIElements;
-using Aspid.FastTools.UIElements;
using Aspid.FastTools.UIElements.Editors.Internal;
// ReSharper disable once CheckNamespace
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeSettingsUI.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeSettingsUI.cs.meta
similarity index 100%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeSettingsUI.cs.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeSettingsUI.cs.meta
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeView.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeView.cs
similarity index 77%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeView.cs
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeView.cs
index cca5caf4..1f52be30 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeView.cs
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeView.cs
@@ -2,7 +2,6 @@
using System.IO;
using UnityEditor;
using UnityEngine;
-using System.Collections.Generic;
using UnityEngine.UIElements;
using Aspid.FastTools.UIElements;
using UnityEditor.PackageManager.UI;
@@ -14,12 +13,15 @@ namespace Aspid.FastTools.Editors
{
///
/// The Welcome panel as a reusable element: hero, samples list, the logo asset-store link and the cursor toast.
- /// Hosted as the leftmost "home" tab of the Managed References window (see SerializeReferenceWindow).
+ /// Hosted as the leftmost "home" tab of the Managed References window (see TabWindow).
/// The UI is cloned from the same UXML the standalone window used; only the toast positioning is
/// retargeted from the window root to this view (the view sits below the tab strip).
///
internal sealed class WelcomeView : VisualElement
{
+ private const string UssClassPrefix = "aspid-fasttools-welcome__";
+ private const string UxmlResourcePath = "UI/Windows/Welcome/Aspid-FastTools-Welcome";
+
private const long ToastVisibleDurationMs = 2500;
private const float ToastEdgeMargin = 8f;
private const float ToastCursorOffset = 16f;
@@ -36,31 +38,30 @@ internal sealed class WelcomeView : VisualElement
private const string GitHubLinkName = "welcome-link-github";
private const string StoreLinkName = "welcome-link-store";
- private const string UxmlResourcePath = "UI/Windows/Welcome/Aspid-FastTools-Welcome";
private const string LogoName = "welcome-logo";
private const string ToastName = "welcome-toast";
private const string ScrollName = "welcome-scroll";
private const string SamplesListName = "welcome-samples-list";
- private const string ToastVisibleClass = "aspid-fasttools-welcome__toast--visible";
-
- private const string NavTargetClass = "aspid-fasttools-welcome__nav-target";
-
- private const string SampleCardClass = "aspid-fasttools-welcome__sample";
- private const string SampleHeaderHoverClass = "aspid-fasttools-welcome__sample--header-hover";
- private const string SampleHeaderRowClass = "aspid-fasttools-welcome__sample-header-row";
- private const string SampleHeaderClass = "aspid-fasttools-welcome__sample-header";
- private const string SampleHeaderRemoveClass = "aspid-fasttools-welcome__sample-header--remove";
- private const string SampleInfoClass = "aspid-fasttools-welcome__sample-info";
- private const string SampleTitleClass = "aspid-fasttools-welcome__sample-title";
- private const string SampleStateDotClass = "aspid-fasttools-welcome__sample-state-dot";
- private const string SampleStateDotImportedClass = "aspid-fasttools-welcome__sample-state-dot--imported";
- private const string SampleDividerClass = "aspid-fasttools-welcome__sample-divider";
- private const string SampleSweepClass = "aspid-fasttools-welcome__sample-sweep";
- private const string SampleSweepRemoveClass = "aspid-fasttools-welcome__sample-sweep--remove";
- private const string SampleDescriptionClass = "aspid-fasttools-welcome__sample-description";
-
- private Label _toast;
+
+ private const string SampleCardClass = UssClassPrefix + "sample";
+ private const string NavTargetClass = UssClassPrefix + "nav-target";
+ private const string SampleInfoClass = UssClassPrefix + "sample-info";
+ private const string SampleTitleClass = UssClassPrefix + "sample-title";
+ private const string SampleSweepClass = UssClassPrefix + "sample-sweep";
+ private const string SampleHeaderClass = UssClassPrefix + "sample-header";
+ private const string ToastVisibleClass = UssClassPrefix + "toast--visible";
+ private const string SampleDividerClass = UssClassPrefix + "sample-divider";
+ private const string SampleStateDotClass = UssClassPrefix + "sample-state-dot";
+ private const string SampleHeaderRowClass = UssClassPrefix + "sample-header-row";
+ private const string SampleDescriptionClass = UssClassPrefix + "sample-description";
+ private const string SampleHeaderHoverClass = UssClassPrefix + "sample--header-hover";
+ private const string SampleSweepRemoveClass = UssClassPrefix + "sample-sweep--remove";
+ private const string SampleHeaderRemoveClass = UssClassPrefix + "sample-header--remove";
+ private const string SampleStateDotImportedClass = UssClassPrefix + "sample-state-dot--imported";
+
+ private readonly Label _toast;
+
private ScrollView _scroll;
private VisualElement _samplesList;
private IVisualElementScheduledItem _toastShow;
@@ -100,7 +101,6 @@ public WelcomeView()
_ring = new NavRing(
host: this,
navTargetClass: NavTargetClass,
- paint: PaintNavFocus,
scrollTo: element => _scroll?.ScrollTo(element));
PopulateSamples(this);
@@ -108,30 +108,11 @@ public WelcomeView()
SetUpHeroLinks(this);
}
- // ---------------------------------------------------------------------------------------------------------
- // Keyboard navigation
- // ---------------------------------------------------------------------------------------------------------
-
- // Sample headers paint their hover in code (accent overlay + tinted labels + divider sweep), so keyboard focus
- // drives that same programmatic hover instead of a USS focused class.
- private static void PaintNavFocus(VisualElement element, bool on)
- {
- if (element is AspidGradientButton button) button.Highlighted = on;
- SetHeaderSweep(element, on);
- }
-
- // A focused sample header also lights its card's divider sweep — the same card-level hover mirror the
- // mouse path drives in CreateSampleCard — so keyboard focus and mouse hover render identically.
- private static void SetHeaderSweep(VisualElement element, bool on)
+ private static void SetUpHeroLinks(VisualElement root)
{
- if (!element.ClassListContains(SampleHeaderClass)) return;
-
- for (var ancestor = element.parent; ancestor is not null; ancestor = ancestor.parent)
- {
- if (!ancestor.ClassListContains(SampleCardClass)) continue;
- ancestor.EnableInClassList(SampleHeaderHoverClass, on);
- return;
- }
+ SetUpLink(root, DocsLinkName, DocumentationUrl);
+ SetUpLink(root, GitHubLinkName, GitHubUrl);
+ SetUpLink(root, StoreLinkName, AssetStoreUrl);
}
private static void SetUpLogoLink(VisualElement root)
@@ -140,13 +121,6 @@ private static void SetUpLogoLink(VisualElement root)
logo?.AddManipulator(new Clickable(() => Application.OpenURL(AssetStoreUrl)));
}
- private static void SetUpHeroLinks(VisualElement root)
- {
- SetUpLink(root, DocsLinkName, DocumentationUrl);
- SetUpLink(root, GitHubLinkName, GitHubUrl);
- SetUpLink(root, StoreLinkName, AssetStoreUrl);
- }
-
private static void SetUpLink(VisualElement root, string name, string url)
{
var link = root.Q(name);
@@ -222,20 +196,17 @@ private void RebuildSamplesList()
{
if (_samplesList is null) return;
- // An Import/Remove triggered from the keyboard rebuilds the very ring it came from — put the
- // highlight back on the same slot (clamped: Remove may shrink nothing, but stay safe) so the
- // keyboard flow continues from where it acted.
- var keepIndex = _ring.Index;
-
_samplesList.Clear();
- _ring.Clear();
- var package = PackageInfo.FindForPackageName(PackageName);
- if (package is not null) AddUpmSamples(package);
- else if (AssetDatabase.IsValidFolder(SamplesPath)) AddLocalSamples();
-
- if (keepIndex >= 0 && _ring.Count > 0)
- _ring.Focus(Mathf.Min(keepIndex, _ring.Count - 1), scrollTo: false);
+ // The cards are rebuilt from scratch, so an Import/Remove triggered from the keyboard rebuilds the very
+ // ring it came from — Rebuild puts the highlight back on the same slot, so the keyboard flow continues
+ // from where it acted.
+ _ring.Rebuild(() =>
+ {
+ var package = PackageInfo.FindForPackageName(PackageName);
+ if (package is not null) AddUpmSamples(package);
+ else if (AssetDatabase.IsValidFolder(SamplesPath)) AddLocalSamples();
+ });
}
private void AddUpmSamples(PackageInfo package)
@@ -295,14 +266,12 @@ private VisualElement CreateSampleCard(
var action = new AspidGradientButton(actionText, evt => onClick(GetMousePosition(evt)))
.AddClass(SampleHeaderClass);
- _ring.Register(action, () => onClick(action.worldBound.center));
if (imported == true)
action.AddClass(SampleHeaderRemoveClass);
- // The sweep sits outside the button (riding the divider below), so USS :hover can't reach it — the button
- // mirrors its hover onto a card modifier the sweep rule listens to, staying lit while the card is the
- // nav-focused ring member (else a mouse pass over a keyboard-focused card would half-extinguish it).
- HoverSweep.MirrorHover(action, () => card, SampleHeaderHoverClass, () => _ring.IsFocused(action));
+ // Registered as the card's header, so the ring drives both halves of the sweep: the divider sweep below
+ // sits outside the button and rides a card modifier, lit by mouse hover and by keyboard focus alike.
+ _ring.RegisterHeader(action, card, SampleHeaderHoverClass, () => onClick(action.worldBound.center));
var info = new VisualElement()
.AddClass(SampleInfoClass)
@@ -423,9 +392,9 @@ private static string ToProjectRelativePath(string path)
var dataPath = Application.dataPath.Replace('\\', '/');
if (!dataPath.EndsWith("/Assets", StringComparison.Ordinal)) return normalized;
- var projectRoot = dataPath.Substring(0, dataPath.Length - "Assets".Length);
+ var projectRoot = dataPath[..^"Assets".Length];
return normalized.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase)
- ? normalized.Substring(projectRoot.Length)
+ ? normalized[projectRoot.Length..]
: normalized;
}
}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeView.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeView.cs.meta
similarity index 100%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeView.cs.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeView.cs.meta
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeWindowStartup.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeWindowStartup.cs
similarity index 91%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeWindowStartup.cs
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeWindowStartup.cs
index 681f5c0b..4602a81a 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeWindowStartup.cs
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeWindowStartup.cs
@@ -9,7 +9,7 @@ namespace Aspid.FastTools.Editors
{
///
/// Per-version auto-show for the Welcome panel. The Welcome content lives as the "home" tab of the
- /// Managed References window ( ), so on the first launch after an install
+ /// Managed References window ( ), so on the first launch after an install
/// or a package update we open that window on its home tab instead of a standalone window. Owns the
/// per-project, per-package-version "seen" flag that gates the auto-show.
///
@@ -56,18 +56,19 @@ private static void TryShowOnStartup()
SessionState.SetBool(SessionKey, true);
if (Application.isBatchMode) return;
+
// The user's opt-out (Settings tab / Preferences → Welcome) gates every auto-show; the manual menu entry
// is untouched by it.
if (!WelcomeSettings.AutoShowEnabled) return;
if (HasBeenSeen) return;
if (HasOpenWindow()) return;
- SerializeReferenceWindow.OpenWelcome();
+ TabWindow.OpenWelcome();
}
private static bool HasOpenWindow()
{
- var windows = Resources.FindObjectsOfTypeAll();
+ var windows = Resources.FindObjectsOfTypeAll();
return windows is { Length: > 0 };
}
}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeWindowStartup.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeWindowStartup.cs.meta
similarity index 100%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeWindowStartup.cs.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Windows/Welcome/WelcomeWindowStartup.cs.meta
diff --git a/CLAUDE.md b/CLAUDE.md
index 97c67954..0d9f6c68 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,73 +1,76 @@
-## Repository Overview
+## Обзор репозитория
-**Aspid.FastTools** is a Unity package (`tech.aspid.fasttools`) that minimizes routine boilerplate code. Three components:
+**Aspid.FastTools** — Unity-пакет (`tech.aspid.fasttools`), минимизирующий рутинный шаблонный код. Три компонента:
-1. **`Aspid.FastTools/`** — Unity project with the package source (Runtime + Editor)
-2. **`Aspid.FastTools.Generators/`** — standalone .NET solution with Roslyn source generators; pipeline patterns and per-generator details in `Aspid.FastTools.Generators/CLAUDE.md`
-3. **`Aspid.FastTools.Analyzers/`** — standalone .NET solution with Roslyn analyzers validating package-attribute usage (`AFT*` diagnostics)
+1. **`Aspid.FastTools/`** — Unity-проект с исходниками пакета (Runtime + Editor)
+2. **`Aspid.FastTools.Generators/`** — отдельное .NET-решение с генераторами исходного кода Roslyn; паттерны конвейера и детали по каждому генератору — в `Aspid.FastTools.Generators/CLAUDE.md`
+3. **`Aspid.FastTools.Analyzers/`** — отдельное .NET-решение с анализаторами Roslyn, проверяющими использование атрибутов пакета (диагностики `AFT*`)
-Repo-internal working documents (roadmap, release checklist, `QA-CHECKLIST.md`/`QA-CHECKLIST_RU.md`, `DESIGN.md`) live in `docs/` — distinct from the package's user-facing `Documentation/`. A new feature must add its QA-checklist item in **both** languages before its branch merges.
+Внутренние рабочие документы репозитория (roadmap, чек-лист релиза, `QA-CHECKLIST.md`/`QA-CHECKLIST_RU.md`, `DESIGN.md`) живут в `docs/` — в отличие от пользовательской `Documentation/` пакета. Новая функциональность обязана добавить свой пункт в QA-чек-лист на **обоих** языках до того, как её ветка будет влита.
-### Building
+### Сборка
-The Unity package itself has no CLI build — Unity compiles it when the project is open. Both Roslyn DLLs ship prebuilt inside the package; the `build-generator` / `build-analyzer` skills hold the exact build/test/deploy commands (PostToolUse hooks also rebuild them automatically on edit — see *Local Claude Code automation*).
+У самого Unity-пакета нет CLI-сборки — Unity компилирует его, когда проект открыт. Обе Roslyn-DLL поставляются внутри пакета уже собранными; скиллы `build-generator` / `build-analyzer` хранят точные команды сборки/тестов/развёртывания (хуки PostToolUse также пересобирают их автоматически при редактировании — см. *Локальная автоматизация Claude Code*).
-- **Generator** (`Aspid.FastTools.Generators/`): on build, `ILRepack.targets` merges the `Aspid.Generators.Helper*` dependencies into a single-file DLL and `Directory.Build.targets` auto-copies it into the Unity package. Never reference `SourceGenerator.Foundations` — its injected `Console` logging deadlocks Unity's compiler server.
-- **Analyzer** (`Aspid.FastTools.Analyzers/`): `Directory.Build.targets` auto-copies the DLL into the Unity package. The copy is **Release-only** on purpose — the Tests and Sample projects reference the analyzer, so a Debug `dotnet test` run would otherwise overwrite the shipped Release DLL.
-- The committed `*.dll.meta` files carry the `RoslynAnalyzer` label with every platform excluded. Diagnostic ID prefixes: analyzer `AFT*`, generator `AFID*`.
+- **Генератор** (`Aspid.FastTools.Generators/`): при сборке `ILRepack.targets` сливает зависимости `Aspid.Generators.Helper*` в однофайловую DLL, а `Directory.Build.targets` автоматически копирует её в Unity-пакет. Никогда не ссылайся на `SourceGenerator.Foundations` — его внедряемое логирование через `Console` вводит сервер компиляции Unity во взаимную блокировку.
+- **Анализатор** (`Aspid.FastTools.Analyzers/`): `Directory.Build.targets` автоматически копирует DLL в Unity-пакет. Копирование намеренно происходит **только для Release** — проекты Tests и Sample ссылаются на анализатор, поэтому прогон `dotnet test` в Debug иначе затёр бы поставляемую Release-DLL.
+- Закоммиченные файлы `*.dll.meta` несут метку `RoslynAnalyzer` со всеми исключёнными платформами. Префиксы идентификаторов диагностик: анализатор — `AFT*`, генератор — `AFID*`.
-## Architecture
+## Архитектура
-### Assemblies (package root: `Aspid.FastTools/Packages/tech.aspid.fasttools/`)
+### Сборки (корень пакета: `Aspid.FastTools/Packages/tech.aspid.fasttools/`)
-| Assembly | Location | Purpose |
+| Сборка | Расположение | Назначение |
|---|---|---|
-| `Aspid.FastTools` | `Source/` | Pure C#, no Unity dependency |
-| `Aspid.FastTools.Unity` | `Unity/Runtime/` | Ships with player builds |
-| `Aspid.FastTools.Unity.VisualElements.Math` | `Unity/Runtime/VisualElements/Extensions/INotifyValueChanged/Math/` | Satellite: `INotifyValueChanged` for `float2/3/4` etc. |
-| `Aspid.FastTools.Unity.Editor` | `Unity/Editor/Scripts/` | Editor-only, excluded from builds |
-| `Aspid.FastTools.Unity.Editor.SerializeReferences.Yaml` | `Unity/Editor/Scripts/SerializeReferences/Yaml/` | Asset-YAML parsing, isolated on purpose |
+| `Aspid.FastTools` | `Source/` | Чистый C#, без зависимости от Unity |
+| `Aspid.FastTools.Unity` | `Unity/Runtime/` | Поставляется со сборками игры |
+| `Aspid.FastTools.Unity.VisualElements.Math` | `Unity/Runtime/VisualElements/Extensions/INotifyValueChanged/Math/` | Сателлит: `INotifyValueChanged` для `float2/3/4` и т.д. |
+| `Aspid.FastTools.Unity.Editor` | `Unity/Editor/Scripts/` | Только редактор, исключается из сборок |
+| `Aspid.FastTools.Unity.Editor.SerializeReferences.Yaml` | `Unity/Editor/Scripts/SerializeReferences/Yaml/` | Разбор YAML ассетов, изолирован намеренно |
-Plus: `Tests/Editor/` (Unity Test Runner), `Samples~/` (UPM tilde convention — imported via Package Manager), `Unity/Editor/Resources/UI|Icons/`.
+Плюс: `Tests/Editor/` (Unity Test Runner), `Samples~/` (соглашение UPM с тильдой — импортируется через Package Manager), `Unity/Editor/Resources/UI|Icons/`.
-**Assembly boundary rule:** `Unity/Runtime/` code must NOT reference `UnityEditor` — it ships with player builds.
+**Правило границы сборок:** код в `Unity/Runtime/` НЕ должен ссылаться на `UnityEditor` — он поставляется со сборками игры.
-**Optional Mathematics integration:** new Mathematics-dependent code goes in the satellite `Aspid.FastTools.Unity.VisualElements.Math` assembly, compiled only when `com.unity.mathematics` is installed (via `versionDefines` declaring `ASPID_FASTTOOLS_UNITY_MATHEMATICS_INTEGRATION`). Only the satellite asmdef declares that symbol — the main runtime asmdef does not.
+**Опциональная интеграция с Mathematics:** новый код, зависящий от Mathematics, размещается в сателлитной сборке `Aspid.FastTools.Unity.VisualElements.Math`, компилируемой только когда установлен `com.unity.mathematics` (через `versionDefines`, объявляющий `ASPID_FASTTOOLS_UNITY_MATHEMATICS_INTEGRATION`). Только asmdef сателлита объявляет этот символ — основной runtime-asmdef этого не делает.
-### Feature map
+### Карта функциональности
-Feature folders under `Unity/Runtime/` and `Unity/Editor/Scripts/` are named after the feature (`Enums`, `Ids`, `ProfilerMarkers`, `Types`, `VisualElements`, `IMGUI`, `SerializedProperties`, `Settings`, `Welcome`, `SerializeReferences`, `Extensions`) — `ls` finds a feature faster than this file can list it. Only what the layout does *not* tell you:
+Папки функциональности внутри `Unity/Runtime/` и `Unity/Editor/Scripts/` названы по самой функциональности (`Enums`, `Ids`, `ProfilerMarkers`, `Types`, `VisualElements`, `IMGUI`, `SerializedProperties`, `Settings`, `Windows`, `SerializeReferences`, `Extensions`) — `ls` найдёт нужное быстрее, чем этот файл сможет всё перечислить. Только то, о чём структура папок *не* говорит:
-| Feature | Non-obvious bits |
+| Функциональность | Неочевидные детали |
|---|---|
-| ProfilerMarkers | `this.Marker()` returns a call-site-unique `ProfilerMarker` — the source generator emits one per (class, method, line) |
-| TypeSelector | One attribute, two field shapes — a `string` (AQN) and a `[SerializeReference]` managed reference. **The managed-reference path lives under `SerializeReferences/`, not `Types/`.** Details: `Unity/Editor/Scripts/Types/CLAUDE.md` |
-| SerializeReference tooling | `SerializeReferenceWindow` (menu `Tools/Aspid 🐍/FastTools/…`), tabs Welcome / Asset References / Project References / Settings; subsystems `Windows/`, `Index/`, `Diagnostics/`, `Yaml/` (own asmdef) |
-| Id Registries | Spans `Unity/Runtime/Ids/` + `Unity/Editor/Scripts/Ids/`. `IdRegistry` (ScriptableObject) maps names to stable int IDs; each `IId` struct binds to exactly **one** registry (enforced by `IdRegistryResolver`); `IdStructGenerator` emits the struct boilerplate. Editor internals: `Unity/Editor/Scripts/Ids/CLAUDE.md` |
-| Settings / Preferences | Per-feature settings live next to their feature; `AspidFastToolsPreferencesProvider` + `AspidSettingsUI` and the window's **Settings** tab only aggregate them |
-| Internal editor components | Strict four-part layout per component (element + `{Name}Preset` + fluent extensions + `Styles/`) — follow it when adding one. Conventions: `Unity/Editor/Scripts/VisualElements/Internal/CLAUDE.md` |
-| VisualElement extensions | Runtime fluent API in `Unity/Runtime/VisualElements/Extensions/`; editor-side command extensions in `Unity/Editor/Scripts/VisualElements/Extensions/` |
-| Welcome view | Not its own window — a tab of `SerializeReferenceWindow`, plus `WelcomeWindowStartup` (auto-show on first import); lists installable samples from `package.json` |
+| ProfilerMarkers | `this.Marker()` возвращает уникальный для места вызова `ProfilerMarker` — генератор исходников выпускает по одному на (класс, метод, строку) |
+| TypeSelector | Один атрибут, две формы поля — `string` (AQN) и управляемая ссылка `[SerializeReference]`. **Путь управляемых ссылок живёт в `SerializeReferences/`, а не в `Types/`.** Детали: `Unity/Editor/Scripts/Types/CLAUDE.md` |
+| Инструментарий SerializeReference | Всё, что стоит за поверхностью инспектора — `Drawers/`, `VisualElements/` (поля + промпт имени), `Index/`, `Diagnostics/`, `Settings/`, `Editing/`, `Yaml/` (собственный asmdef). **Окно, которое всё это представляет, живёт в `Unity/Editor/Scripts/Windows/`, а не здесь** |
+| Правки SerializeReference | `SerializeReferences/Editing/` владеет каждой мутацией, свободен от UI и вызываем из тестов: `SerializeReferenceGraphEditor` (по одной записи за раз), `SerializeReferenceBatchEditor` (пакеты по файлам), `MissingReferenceGroup` (модель сканирования проекта), `SerializeReferenceOpenCopyGuard` (никогда не переписывать файл, чья открытая копия его затрёт), `SerializeReferenceConstraintCache`. **Представление никогда не редактирует ассет само** — оно обращается сюда и решает лишь, перерисовываться ли |
+| Окно редактора и вкладки | `Unity/Editor/Scripts/Windows/` — `SerializeReferenceWindow` (меню `Tools/Aspid 🐍/FastTools/…`) плюс каждая размещаемая в нём вкладка, по одной подпапке на каждую: `Welcome/`, `References/`, `Settings/`. Каждое представление сообщает окну `StatusStyle.Type`, а окно заливает им общий точечный холст — цвета заливки живут в USS компонента холста, никогда в коде представлений. Навигация с клавиатуры — общий `NavRing` |
+| Вкладка References | `Windows/References/` делится натрое: `Asset/` и `Project/` содержат по одному представлению аудита — `partial` на каждую зону ответственности (обвязка+сканирование / карточки / пикер или действия) плюс чистые `*Summary` (тексты) и `*Analysis` (подсчёт), — а `Shared/` содержит то, что носят оба: `SerializeReferenceAuditUI` (формулировки счётчиков, выделяемые строки, легенда, янтарно-синий вердикт `ResolveStatus`), `AuditPickerHost` (стыковка встроенного пикера), `ManagedReferenceFilter`, `ViolationFieldLabels`. Добавление формы карточки означает новый `Build*` в partial-файле карточек вкладки, а не новую ветку в представлении |
+| Реестры Id | Охватывают `Unity/Runtime/Ids/` + `Unity/Editor/Scripts/Ids/`. `IdRegistry` (ScriptableObject) сопоставляет имена со стабильными целочисленными ID; каждая структура `IId` привязывается ровно к **одному** реестру (обеспечивается `IdRegistryResolver`); `IdStructGenerator` выпускает шаблонный код структур. Внутренности редактора: `Unity/Editor/Scripts/Ids/CLAUDE.md` |
+| Settings / Preferences | Настройки каждой функциональности живут рядом с ней; `AspidFastToolsPreferencesProvider` + `AspidSettingsUI` и вкладка **Settings** окна только агрегируют их |
+| Внутренние компоненты редактора | Строгая четырёхчастная структура на компонент (элемент + `{Name}Preset` + fluent-расширения + `Styles/`) — следуй ей при добавлении нового. Соглашения: `Unity/Editor/Scripts/VisualElements/Internal/CLAUDE.md` |
+| Расширения VisualElement | Runtime fluent API в `Unity/Runtime/VisualElements/Extensions/`; командные расширения на стороне редактора в `Unity/Editor/Scripts/VisualElements/Extensions/` |
+| Представление Welcome | Не отдельное окно — вкладка `SerializeReferenceWindow` (`Windows/Welcome/`), плюс `WelcomeWindowStartup` (автопоказ при первом импорте); перечисляет устанавливаемые примеры из `package.json` |
-### Editor Code Conventions
+### Соглашения по коду редактора
-**Member accessibility:** in an `internal` class, members must be declared `internal` (or narrower), never `public` — the member's own modifier should show its real accessibility without checking the containing class.
+**Доступность членов:** в `internal` классе члены должны объявляться как `internal` (или уже), никогда как `public` — собственный модификатор члена должен показывать его реальную доступность без проверки содержащего класса.
-**PropertyDrawers:** Always `internal sealed class`. Complex drawers split into a static helper `{Feature}Drawer` with `DrawIMGUI()` and `DrawUIToolkit()` methods — see `SerializableTypeDrawer.cs` as reference.
+**PropertyDrawer'ы:** всегда `internal sealed class`. Сложные drawer'ы разделяются на статический помощник `{Feature}Drawer` с методами `DrawIMGUI()` и `DrawUIToolkit()` — см. `SerializableTypeDrawer.cs` как образец.
-**XML doc comments:** `` — 1–2 sentences, what/why, no implementation details. `` — only for non-obvious behavior, invariants, or gotchas; omit if it would just restate the summary or the code. `` — only for non-trivial usage patterns where the shape of usage isn't obvious from the signature. Follow Microsoft's Framework Design Guidelines conventions.
+**XML doc-комментарии:** обязательны на каждом `public` члене, экономны на `internal`. `` — 1–2 предложения, что/зачем, без деталей реализации. `` — только для неочевидного поведения, инвариантов или подводных камней; опускай, если это лишь повторит summary или код. `` — только для нетривиальных сценариев использования, где форма применения неочевидна из сигнатуры. Следуй соглашениям Microsoft Framework Design Guidelines.
-**USS:** styling goes in USS, code only applies `.AddClass()`. Naming (BEM classes + variable grammar) and loading conventions: `Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/CLAUDE.md` — read it before touching any `.uss` file or USS class names / `--aspid-*` variables in code.
+**USS:** стилизация идёт в USS, код только применяет `.AddClass()`. Именование (BEM-классы + грамматика переменных) и соглашения по загрузке: `Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/CLAUDE.md` — прочитай перед тем, как трогать любой `.uss` файл или имена USS-классов / переменные `--aspid-*` в коде.
-**README files:** keep 4 in sync: root `README.md`/`README_RU.md` and `Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN|RU/README.md`. Image paths differ: root files use `Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/...`, inner ones `../Images/...`. Per-feature references live alongside each README inside `EN/`/`RU/`.
+**README-файлы:** держи 4 файла синхронными: корневые `README.md`/`README_RU.md` и `Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN|RU/README.md`. Пути к изображениям различаются: корневые файлы используют `Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/...`, внутренние — `../Images/...`. Справочники по каждой функциональности живут рядом с соответствующим README внутри `EN/`/`RU/`.
-### Local Claude Code automation
+### Локальная автоматизация Claude Code
-PostToolUse hooks (wired in `.claude/settings.json`):
+Хуки PostToolUse (подключены в `.claude/settings.json`):
-- `.claude/hooks/rebuild-generators-on-change.sh` — on `Edit`/`Write` to `*.cs` under `Aspid.FastTools.Generators/Aspid.FastTools.Generators/`, rebuilds the generator and redeploys the DLL into the Unity package. Tests and Sample are skipped — keep that scope when changing the hook.
-- `.claude/hooks/rebuild-analyzers-on-change.sh` — same for the analyzer (Tests/Sample skipped): rebuilds it, and `Directory.Build.targets` deploys the DLL.
+- `.claude/hooks/rebuild-generators-on-change.sh` — при `Edit`/`Write` файлов `*.cs` внутри `Aspid.FastTools.Generators/Aspid.FastTools.Generators/` пересобирает генератор и переразвёртывает DLL в Unity-пакет. Tests и Sample пропускаются — сохраняй эту область при изменении хука.
+- `.claude/hooks/rebuild-analyzers-on-change.sh` — то же самое для анализатора (Tests/Sample пропускаются): пересобирает его, а `Directory.Build.targets` разворачивает DLL.
-Skills in `.claude/skills/`: `build-generator` / `build-analyzer` (build + deploy the Roslyn DLLs), `sync-readmes`, `unity-pipeline` (drive the live Editor via the `unity` CLI + `com.unity.pipeline` — recompile/test loop, `eval`, `sr_gate`, authoring `[CliCommand]`s), `editor-media-capture` (docs screenshots/GIFs of editor windows).
+Скиллы в `.claude/skills/`: `build-generator` / `build-analyzer` (сборка + развёртывание Roslyn-DLL), `sync-readmes`, `unity-pipeline` (управление живым редактором через CLI `unity` + `com.unity.pipeline` — цикл перекомпиляции/тестов, `eval`, `sr_gate`, написание `[CliCommand]`), `editor-media-capture` (скриншоты/GIF окон редактора для документации).
-**Driving the Editor:** several Editors run at once (main checkout + `.claude/worktrees/shared-*`), so every `unity command` needs `--project-path`. Use `unity status` for liveness, not `unity pipeline list`. Details and gotchas live in the `unity-pipeline` skill.
+**Управление редактором:** одновременно работают несколько редакторов (основной checkout + `.claude/worktrees/shared-*`), поэтому каждой `unity command` нужен `--project-path`. Для проверки живости используй `unity status`, а не `unity pipeline list`. Детали и подводные камни живут в скилле `unity-pipeline`.
diff --git a/docs/SerializeReference-Roadmap.md b/docs/SerializeReference-Roadmap.md
index f5b26260..b7739e8b 100644
--- a/docs/SerializeReference-Roadmap.md
+++ b/docs/SerializeReference-Roadmap.md
@@ -102,4 +102,4 @@
- Ранжировщик подсказок: `SerializeReferences/Extensions/SerializeReferenceRepairSuggestions.cs` — `[MovedFrom]` даёт 1.0; тот же 1.0 достижим как 0.8 «same type name» + 0.2 field-shape.
- Триггер: `SerializeReferences/Diagnostics/SerializeReferenceBreakageDetector.cs` (обнаружение newly-missing).
- Применение: YAML-rewriter в `SerializeReferences/Yaml/` (`SerializeReferenceYamlEditor.TryRewriteType`).
-- Проектный охват: `SerializeReferences/Index/SerializeReferenceTypeUsageIndex.cs` + Project-свип с готовым **Fix all** (`SerializeReferences/Windows/SerializeReferenceProjectView.cs`).
+- Проектный охват: `SerializeReferences/Index/SerializeReferenceTypeUsageIndex.cs` + Project-свип с готовым **Fix all** (`Windows/References/SerializeReferenceProjectView.cs`).