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