// ReSharper disable AccessToModifiedClosure // ReSharper disable AccessToDisposedClosure // ReSharper disable HeapView.CanAvoidClosure namespace WallstopStudios.DataVisualizer.Editor { #if UNITY_EDITOR using System; using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Text.RegularExpressions; using Data; using Extensions; using Search; #if ODIN_INSPECTOR using Sirenix.OdinInspector; using Sirenix.OdinInspector.Editor; #endif using Styles; using UI; using UnityEditor; using UnityEditor.UIElements; using UnityEngine; using UnityEngine.UIElements; using Utilities; using Helper; using Debug = UnityEngine.Debug; using Object = UnityEngine.Object; public sealed class DataVisualizer : EditorWindow { private const string PackageId = "com.wallstop-studios.data-visualizer"; private const string PrefsPrefix = "WallstopStudios.Editor.DataVisualizer."; private const string PrefsSplitterOuterKey = PrefsPrefix + "SplitterOuterFixedPaneWidth"; private const string PrefsSplitterInnerKey = PrefsPrefix + "SplitterInnerFixedPaneWidth"; private const string PrefsInitialSizeAppliedKey = PrefsPrefix + "InitialSizeApplied"; private const string SettingsDefaultPath = "Assets/Editor/DataVisualizerSettings.asset"; private const string UserStateFileName = "DataVisualizerUserState.json"; private const string NamespaceGroupHeaderClass = "namespace-group-header"; private const string ObjectItemClass = "object-item"; private const string ObjectItemContentClass = "object-item-content"; private const string ObjectItemActionsClass = "object-item-actions"; private const string PopoverListItemClassName = "type-selection-list-item"; private const string PopoverListItemDisabledClassName = "type-selection-list-item--disabled"; private const string PopoverListNamespaceClassName = "type-selection-list-namespace"; private const string PopoverNamespaceHeaderClassName = "popover-namespace-header"; private const string PopoverNamespaceIndicatorClassName = "popover-namespace-indicator"; private const string SearchResultItemClass = "search-result-item"; private const string SearchResultHighlightClass = "search-result-item--highlighted"; private const string PopoverHighlightClass = "popover-item--highlighted"; private const string LabelSuggestionItemClass = "label-suggestion-item"; private const string SearchPlaceholder = "Search..."; private const int MaxSearchResults = 25; private const float DefaultOuterSplitWidth = 350f; private const float DefaultInnerSplitWidth = 250f; private const float MinNamespacePaneWidth = 320f; private const float MinObjectPaneWidth = 220f; private const float MinInspectorPaneWidth = 260f; private const float MinWindowWidth = MinNamespacePaneWidth + MinObjectPaneWidth + MinInspectorPaneWidth + 60f; private const float MinWindowHeight = 480f; private const int AsyncLoadBatchSize = 100; private const int AsyncLoadPriorityBatchSize = 100; // Debug logging for testing async loading // Set to true to see detailed loading performance logs in Unity Console private static readonly bool EnableAsyncLoadDebugLog = false; private enum DragType { None = 0, Namespace = 2, Type = 3, } private enum FocusArea { None = 0, TypeList = 1, AddTypePopover = 2, SearchResultsPopover = 3, } private enum LabelFilterSection { [Obsolete("Please use a valid value")] None = 0, Available = 1, AND = 2, OR = 3, } private static readonly Color[] PredefinedLabelColors = { new Color(0.32f, 0.55f, 0.78f), new Color(0.90f, 0.42f, 0.32f), new Color(0.45f, 0.70f, 0.40f), new Color(0.82f, 0.60f, 0.28f), new Color(0.50f, 0.48f, 0.70f), new Color(0.75f, 0.45f, 0.60f), new Color(0.30f, 0.65f, 0.65f), new Color(0.65f, 0.65f, 0.35f), }; internal static DataVisualizer Instance; private static readonly StringBuilder CachedStringBuilder = new(); internal DataVisualizerUserState UserState { get { #pragma warning disable CS0618 // Type or member is obsolete if (_userState == null) { LoadUserStateFromFile(); } return _userState; #pragma warning restore CS0618 // Type or member is obsolete } } internal DataVisualizerSettings Settings { get { #pragma warning disable CS0618 // Type or member is obsolete if (_settings == null) { _settings = LoadOrCreateSettings(); } return _settings; #pragma warning restore CS0618 // Type or member is obsolete } } private TypeLabelFilterConfig CurrentTypeLabelFilterConfig => LoadOrCreateLabelFilterConfig(_namespaceController.SelectedType); private ProcessorState CurrentProcessorState => LoadOrCreateProcessorState(_namespaceController.SelectedType); private int HiddenNamespaces { #pragma warning disable CS0618 // Type or member is obsolete get => _hiddenNamespaces; set { _hiddenNamespaces = value; if (_namespaceColumnLabel != null) { _namespaceColumnLabel.text = value <= 0 ? "Namespaces" : value <= 15 ? $"Namespaces ({value} hidden)" : $"Namespaces ({value} hidden)"; } } #pragma warning restore CS0618 // Type or member is obsolete } internal readonly Dictionary> _scriptableObjectTypes = new( StringComparer.Ordinal ); private readonly Dictionary _namespaceOrder = new(StringComparer.Ordinal); [Obsolete("Use HiddenNamespaces property instead")] private int _hiddenNamespaces; private readonly List _selectedObjects = new(); private readonly List _allManagedObjectsCache = new(); private readonly List _currentSearchResultItems = new(); private readonly List _currentTypePopoverItems = new(); private readonly NamespaceController _namespaceController; private ScriptableObject _selectedObject; private VisualElement _selectedNamespaceElement; private VisualElement _namespaceListContainer; private VisualElement _inspectorContainer; private ScrollView _inspectorScrollView; // Virtualized list of the selected type's objects (replaces the manual ScrollView + paging). private ListView _objectListView; // Uniform row height for the virtualized object ListView. Measured live: the .object-item // box renders at ~40px; the slot is forced to this height and centers the box, so the extra // 6px becomes a 3px gap above and below each row. private const float ObjectRowFixedHeight = 46f; // Guards the ListView selection callback while selection is set programmatically. private bool _suppressListSelectionCallback; // Action buttons stop pointer-down here so clicking one doesn't retarget the ListView's // selection (which otherwise drops the current selection when using go-up/go-down/etc.) or // start a row drag from a button. private static readonly EventCallback StopRowChildPointerDown = evt => evt.StopPropagation(); private TwoPaneSplitView _outerSplitView; private TwoPaneSplitView _innerSplitView; private VisualElement _namespaceColumnElement; private Label _namespaceColumnLabel; private TextField _assetNameTextField; private VisualElement _objectColumnElement; private Label _objectLoadingIndicator; private VisualElement _settingsPopover; private VisualElement _renamePopover; private VisualElement _createPopover; private VisualElement _confirmDeletePopover; private VisualElement _confirmActionPopover; private VisualElement _typeAddPopover; private VisualElement _activePopover; private VisualElement _confirmNamespaceAddPopover; private VisualElement _activeNestedPopover; private object _popoverContext; private bool _isDraggingPopover; private Vector2 _popoverDragStartMousePos; private Vector2 _popoverDragStartPos; private VisualElement _labelCollapseRow; private Label _labelCollapseToggle; private Label _labels; private Label _labelAdvancedCollapseToggle; private HorizontalToggle _andOrToggle; private VisualElement _labelFilterSelectionRoot; private VisualElement _logicalGrouping; private VisualElement _availableLabelsContainer; private VisualElement _andLabelsContainer; private VisualElement _orLabelsContainer; private Label _filterStatusLabel; private HorizontalToggle _processorLogicToggle; private VisualElement _processorArea; private readonly List _currentUniqueLabelsForType = new(); private readonly List _filteredObjects = new(); private readonly Dictionary _textColorCache = new(StringComparer.Ordinal); private int _nextColorIndex; private VisualElement _inspectorLabelsSection; private VisualElement _inspectorCurrentLabelsContainer; private TextField _inspectorNewLabelInput; private string _draggedLabelText; private LabelFilterSection _dragSourceSection; private VisualElement _inspectorLabelSuggestionsPopover; private readonly List _projectUniqueLabelsCache = new(); private bool _isLabelCachePopulated; private readonly List _currentLabelSuggestionItems = new(); private int _labelSuggestionHighlightIndex = -1; private VisualElement _processorAreaElement; private VisualElement _processorListContainer; private Label _processorToggleCollapseButton; private VisualElement _processorHeader; private Label _processorHeaderLabel; private readonly List _allDataProcessors = new(); private readonly List _compatibleDataProcessors = new(); private TextField _searchField; private VisualElement _searchPopover; private bool _isSearchCachePopulated; // Managed ScriptableObject types for the current search-cache load, computed once per // PopulateSearchCacheAsync run and reused across its batches. private HashSet _searchCacheManagedTypes; // Incremented on each PopulateSearchCacheAsync run so stale scheduled batch callbacks from a // superseded run no-op instead of draining the new queue or marking the cache ready early. private int _searchCacheGeneration; private string _lastSearchString; private Button _addTypeButton; private Label _emptyObjectLabel; private Button _addTypesFromScriptFolderButton; private Button _addTypesFromDataFolderButton; private Button _createObjectButton; private Button _settingsButton; private TextField _typeAddSearchField; private VisualElement _typePopoverListContainer; private TextField _typeSearchField; private float _lastSavedOuterWidth = -1f; private float _lastSavedInnerWidth = -1f; private IVisualElementScheduledItem _saveWidthsTask; private int _searchHighlightIndex = -1; private int _typePopoverHighlightIndex = -1; private string _lastTypeAddSearchTerm; #pragma warning disable CS0618 // Type or member is obsolete private FocusArea _lastActiveFocusArea = FocusArea.None; private DragType _activeDragType = DragType.None; #pragma warning restore CS0618 // Type or member is obsolete private object _draggedData; private VisualElement _inPlaceGhost; private int _lastGhostInsertIndex = -1; private VisualElement _lastGhostParent; private VisualElement _draggedElement; private VisualElement _dragGhost; internal bool _isDragging; private SerializedObject _currentInspectorScriptableObject; private string _userStateFilePath; [Obsolete("Use UserState instead.")] private DataVisualizerUserState _userState; private bool _userStateDirty; [Obsolete("User Settings instead.")] private DataVisualizerSettings _settings; private List _relevantScriptableObjectTypes; private float? _lastAddTypeClicked; private float? _lastSettingsClicked; private float? _lastEnterPressed; private bool _needsRefresh; // Async loading state private Type _asyncLoadTargetType; private IVisualElementScheduledItem _asyncLoadTask; private readonly Queue _pendingObjectGuids = new(); private readonly Queue _pendingSearchCacheGuids = new(); private bool _isLoadingObjectsAsync; private bool _isLoadingSearchCacheAsync; // Total asset count for the in-progress async load, captured once from the // initial FindAssets so per-batch progress updates never rescan the project. private int _asyncLoadTotalCount; // GUIDs FindAssets returned for the loading type that resolve to a missing asset or a subclass // the exact-type load skips; subtracted from the indicator total so it reflects only loadable // assets and doesn't appear stuck below 100%. private int _asyncLoadSkippedCount; // Incremented on each fresh (non-continuation) async load. Scheduled callbacks (auto-select, // the background batch pump) capture this at schedule time and no-op if a newer load has // superseded them, so a stale callback can't select the wrong asset or interleave batches. private int _asyncLoadGeneration; // Canonical display order (asset GUID -> position) for the in-progress async load: saved // custom order first, then the remaining assets by path. LoadObjectBatch positions loaded // assets by this so user ordering survives batched loading instead of being overwritten by // an alphabetical sort. Rebuilt at the start of each LoadObjectTypesAsync. private readonly Dictionary _asyncDisplayOrderByGuid = new( StringComparer.Ordinal ); // Display-order index cached per already-inserted object so batch insertion stays cheap // (no per-comparison AssetDatabase calls). Cleared whenever _selectedObjects is reset. private readonly Dictionary _selectedObjectOrderIndex = new(); private Label _dataFolderPathDisplay; #if ODIN_INSPECTOR private PropertyTree _odinPropertyTree; private IMGUIContainer _odinInspectorContainer; private IVisualElementScheduledItem _odinRepaintSchedule; #endif public DataVisualizer() { _namespaceController = new NamespaceController(_scriptableObjectTypes, _namespaceOrder); } [MenuItem("Tools/Wallstop Studios/Data Visualizer")] public static void ShowWindow() { DataVisualizer window = GetWindow("Data Visualizer"); window.titleContent = new GUIContent("Data Visualizer"); window.minSize = new Vector2(MinWindowWidth, MinWindowHeight); bool initialSizeApplied = EditorPrefs.GetBool(PrefsInitialSizeAppliedKey, false); if (initialSizeApplied) { return; } float width = Mathf.Max(MinWindowWidth, window.position.width); float height = Mathf.Max(MinWindowHeight, window.position.height); Rect monitorArea = MonitorUtility.GetPrimaryMonitorRect(); float centerX = (monitorArea.width - width) / 2f; float centerY = (monitorArea.height - height) / 2f; float x = Mathf.Max(0, centerX); float y = Mathf.Max(0, centerY); window.position = new Rect(x, y, width, height); EditorPrefs.SetBool(PrefsInitialSizeAppliedKey, true); } private void OnEnable() { minSize = new Vector2(MinWindowWidth, MinWindowHeight); _nextColorIndex = 0; Instance = this; _isSearchCachePopulated = false; _selectedObject = null; _selectedObjects.Clear(); #if ODIN_INSPECTOR _odinPropertyTree = null; #endif _userStateFilePath = Path.Combine(Application.persistentDataPath, UserStateFileName); _allDataProcessors.Clear(); IEnumerable processorTypes = TypeCache .GetTypesDerivedFrom() .Where(t => !t.IsAbstract && !t.IsInterface && !t.IsGenericTypeDefinition); foreach (Type type in processorTypes) { try { if (Activator.CreateInstance(type) is IDataProcessor instance) { _allDataProcessors.Add(instance); } } catch (Exception ex) { Debug.LogError( $"Failed to create instance of IDataProcessor '{type.FullName}': {ex.Message}" ); } } _allDataProcessors.Sort((lhs, rhs) => string.CompareOrdinal(lhs.Name, rhs.Name)); // Don't load types here - it blocks the UI from appearing // LoadScriptableObjectTypes() is now deferred to CreateGUI rootVisualElement.RegisterCallback( HandleGlobalKeyDown, TrickleDown.TrickleDown ); } private void OnDisable() { rootVisualElement.UnregisterCallback( HandleGlobalKeyDown, TrickleDown.TrickleDown ); Cleanup(); } private void OnDestroy() { Cleanup(); } private void Cleanup() { if (Instance == this) { Instance = null; } // Cancel async loading _asyncLoadTask?.Pause(); _asyncLoadTask = null; _asyncLoadTargetType = null; _pendingObjectGuids.Clear(); _pendingSearchCacheGuids.Clear(); _isLoadingObjectsAsync = false; _isLoadingSearchCacheAsync = false; UpdateLoadingIndicator(0, 0); // Hide loading indicator _isLabelCachePopulated = false; _selectedObject = null; _scriptableObjectTypes.Clear(); _namespaceOrder.Clear(); _namespaceController.Clear(); _allManagedObjectsCache.Clear(); _currentSearchResultItems.Clear(); _currentTypePopoverItems.Clear(); _isSearchCachePopulated = false; CloseActivePopover(); CancelDrag(); _saveWidthsTask?.Pause(); if (!Settings.persistStateInSettingsAsset && _userStateDirty) { SaveUserStateToFile(); } _saveWidthsTask = null; _currentInspectorScriptableObject?.Dispose(); _currentInspectorScriptableObject = null; _dragGhost?.RemoveFromHierarchy(); _dragGhost = null; _draggedElement = null; #if ODIN_INSPECTOR _odinRepaintSchedule?.Pause(); _odinRepaintSchedule = null; if (_odinPropertyTree != null) { _odinPropertyTree.OnPropertyValueChanged -= HandleOdinPropertyValueChanged; _odinPropertyTree.Dispose(); _odinPropertyTree = null; } _odinInspectorContainer?.RemoveFromHierarchy(); _odinInspectorContainer?.Dispose(); _odinInspectorContainer = null; #endif } private void PopulateSearchCache() { // Start async loading instead PopulateSearchCacheAsync(); } private void PopulateSearchCacheAsync() { var cacheStartTime = System.Diagnostics.Stopwatch.StartNew(); _allManagedObjectsCache.Clear(); _pendingSearchCacheGuids.Clear(); _isLoadingSearchCacheAsync = true; // The cache is being rebuilt; it is not searchable until the batches below finish. _isSearchCachePopulated = false; // Managed-type set for this run, computed once and reused across all batches. _searchCacheManagedTypes = new(_scriptableObjectTypes.SelectMany(tuple => tuple.Value)); int generation = ++_searchCacheGeneration; if (EnableAsyncLoadDebugLog) { Debug.Log( $"[DataVisualizer] PopulateSearchCacheAsync START at {System.DateTime.Now:HH:mm:ss.fff}" ); } HashSet uniqueGuids = new(StringComparer.OrdinalIgnoreCase); // Collect all GUIDs first (fast, no asset loading) foreach (Type type in _scriptableObjectTypes.SelectMany(tuple => tuple.Value)) { string[] guids = AssetDatabase.FindAssets($"t:{type.Name}"); foreach (string guid in guids) { if (uniqueGuids.Add(guid)) { _pendingSearchCacheGuids.Enqueue(guid); } } } // Do NOT mark the cache populated here — the assets aren't loaded until the batches // below finish. Marking it ready after only collecting GUIDs made PerformSearch run // against an empty/partial cache. It is marked populated on completion instead. cacheStartTime.Stop(); if (EnableAsyncLoadDebugLog) { Debug.Log( $"[DataVisualizer] Search cache GUID collection: {_pendingSearchCacheGuids.Count} GUIDs collected in {cacheStartTime.ElapsedMilliseconds}ms" ); } // Start loading batches if (_pendingSearchCacheGuids.Count > 0) { ContinuePopulatingSearchCache(generation); } else { _isLoadingSearchCacheAsync = false; _isSearchCachePopulated = true; // nothing to load; the (empty) cache is ready RefreshActiveSearch(); // resolve any "Building search index…" popover immediately } } private void ContinuePopulatingSearchCache(int generation) { if (generation != _searchCacheGeneration) { return; // superseded by a newer PopulateSearchCacheAsync run } if (!_isLoadingSearchCacheAsync || _pendingSearchCacheGuids.Count == 0) { _isLoadingSearchCacheAsync = false; return; } int batchSize = Mathf.Min(AsyncLoadBatchSize, _pendingSearchCacheGuids.Count); List batch = DequeueBatch(_pendingSearchCacheGuids, batchSize); // Load batch. GUIDs in _pendingSearchCacheGuids are already unique (deduped up front), // so no per-batch de-duplication is needed here. List loadedObjects = new(); foreach (string guid in batch) { string path = AssetDatabase.GUIDToAssetPath(guid); if (string.IsNullOrWhiteSpace(path)) { continue; } ScriptableObject obj = AssetDatabase.LoadMainAssetAtPath(path) as ScriptableObject; if (obj != null) { // Verify it's a managed type (O(1) against the set cached for this run). No cache // membership check needed: the cache was cleared and GUIDs are unique, so the // object can't already be present, and Contains() grows costlier as it fills. if (_searchCacheManagedTypes.Contains(obj.GetType())) { loadedObjects.Add(obj); } } } // Append this batch. The cache is sorted once when loading completes (below); it isn't // used for search until then, so per-item BinarySearch+Insert would just be wasted // O(n^2) shifting work. _allManagedObjectsCache.AddRange(loadedObjects); // Continue with next batch if (_pendingSearchCacheGuids.Count > 0) { rootVisualElement .schedule.Execute(() => ContinuePopulatingSearchCache(generation)) .ExecuteLater(10); } else { _isLoadingSearchCacheAsync = false; // Sort the fully-loaded cache once (search reads it sorted by name then type). _allManagedObjectsCache.Sort( (a, b) => { int nameComp = string.Compare(a.name, b.name, StringComparison.Ordinal); return nameComp != 0 ? nameComp : string.Compare( a.GetType().FullName, b.GetType().FullName, StringComparison.Ordinal ); } ); _isSearchCachePopulated = true; RefreshActiveSearch(); } } // Re-runs the active search once the search cache finishes loading, so results that were // unavailable while it loaded in the background appear. Only acts while the search popover is // open, so it never reopens a popover the user has dismissed. private void RefreshActiveSearch() { if (_activePopover != _searchPopover || string.IsNullOrWhiteSpace(_lastSearchString)) { return; } string current = _lastSearchString; _lastSearchString = null; // bypass the no-op guard in PerformSearch PerformSearch(current); } // Removes and returns up to batchSize items from the front of the queue. A Queue keeps this // O(batchSize) instead of the O(n) element shift List.RemoveRange(0, batchSize) incurs each // batch (which compounds to O(n^2) over a full drain on large projects). private static List DequeueBatch(Queue queue, int batchSize) { List batch = new(batchSize); for (int i = 0; i < batchSize && queue.Count > 0; i++) { batch.Add(queue.Dequeue()); } return batch; } public static void SignalRefresh() { DataVisualizer window = Instance; if (window != null) { window.ScheduleRefresh(); } } private void ScheduleRefresh() { if (_needsRefresh) { return; } _needsRefresh = true; rootVisualElement.schedule.Execute(RefreshAllViews).ExecuteLater(1); } private void SyncNamespaceAndTypeOrders() { List namespaceOrder = _namespaceOrder .OrderBy(kvp => kvp.Value) .Select(kvp => kvp.Key) .ToList(); List typeOrder = _namespaceOrder .OrderBy(kvp => kvp.Value) .Select(kvp => new NamespaceTypeOrder { namespaceKey = kvp.Key, typeNames = _scriptableObjectTypes[kvp.Key] .Select(type => type.FullName) .ToList(), }) .ToList(); PersistSettings( settings => { settings.namespaceOrder = namespaceOrder; settings.typeOrders = typeOrder; return true; }, userState => { userState.namespaceOrder = namespaceOrder; userState.typeOrders = typeOrder; return true; } ); } internal void PersistSettings( Func settingsApplier, Func userStateApplier ) { DataVisualizerSettings settings = Settings; if (settings.persistStateInSettingsAsset) { if (settingsApplier(settings)) { settings.MarkDirty(); AssetDatabase.SaveAssets(); } } else if (userStateApplier(UserState)) { MarkUserStateDirty(); } } public static bool ApplySelectActiveObjectPreference( DataVisualizerSettings settings, bool selectActiveObject ) { if (settings == null || !settings.SetSelectActiveObject(selectActiveObject)) { return false; } AssetDatabase.SaveAssets(); return true; } private void RefreshAllViews() { Type selectedType = _namespaceController.SelectedType; string previousNamespaceKey = selectedType != null ? NamespaceController.GetNamespaceKey(selectedType) : string.Empty; string previousTypeFullName = selectedType?.FullName; string previousObjectGuid = null; if (_selectedObject != null) { string path = AssetDatabase.GetAssetPath(_selectedObject); if (!string.IsNullOrWhiteSpace(path)) { previousObjectGuid = AssetDatabase.AssetPathToGUID(path); } } LoadScriptableObjectTypes(); selectedType = ResolveSelectedTypeByFullName( _scriptableObjectTypes, _namespaceOrder, previousNamespaceKey, previousTypeFullName ); // Load once. For an unchanged type the SelectType call near the end no-ops its own load, // so reload here (a refresh must re-scan). For a changed type, let that single SelectType // call do the load — doing both would load the same type twice. bool selectionTypeChanged = _namespaceController.SelectedType != selectedType; if (selectedType == null) { _selectedObjects.Clear(); } else if (!selectionTypeChanged) { LoadObjectTypesAsync(selectedType); } else { // The refresh resolved a different type (e.g. the previous type was removed); the // SelectType call below will load it. Clear the old type's objects now so the object // column doesn't show the previous type's assets until that async load's view rebuild. _selectedObjects.Clear(); _filteredObjects.Clear(); _selectedObjectOrderIndex.Clear(); } ScriptableObject selectedObject = _selectedObject; if ( selectedType != null && !string.IsNullOrWhiteSpace(previousObjectGuid) && 0 < _selectedObjects.Count ) { selectedObject = _selectedObjects.Find(obj => { if (obj == null) { return false; } string path = AssetDatabase.GetAssetPath(obj); return !string.IsNullOrWhiteSpace(path) && string.Equals( AssetDatabase.AssetPathToGUID(path), previousObjectGuid, StringComparison.OrdinalIgnoreCase ); }); } PopulateSearchCache(); BuildNamespaceView(); BuildObjectsView(); VisualElement typeElementToSelect = FindTypeElement(selectedType); if (typeElementToSelect != null) { VisualElement ancestorGroup = FindAncestorNamespaceGroup(typeElementToSelect); if (ancestorGroup != null) { ExpandNamespaceGroupIfNeeded(ancestorGroup, false); } } // Only select eagerly if the previously-selected object is already loaded (found in the // priority batch). Otherwise leave it to LoadObjectTypesAsync's generation-guarded // deferred auto-select, which restores the real selection once its batch loads instead of // persisting a fallback GUID over it. if (selectedObject != null && _selectedObjects.Contains(selectedObject)) { SelectObject(selectedObject); } _namespaceController.SelectType(this, selectedType); _needsRefresh = false; } private VisualElement FindAncestorNamespaceGroup(VisualElement startingElement) { return FindAncestorNamespaceGroup(startingElement, _namespaceListContainer); } public static VisualElement FindAncestorNamespaceGroup( VisualElement startingElement, VisualElement namespaceListContainer ) { if (startingElement == null) { return null; } VisualElement currentElement = startingElement; while (currentElement != null && currentElement != namespaceListContainer) { if (currentElement.ClassListContains(StyleConstants.NamespaceItemClass)) { return currentElement; } currentElement = currentElement.parent; } return null; } public static string FindFirstNamespaceKeyByOrder( IReadOnlyDictionary namespaceOrder ) { if (namespaceOrder == null || namespaceOrder.Count == 0) { return null; } string firstNamespaceKey = null; int firstNamespaceIndex = int.MaxValue; foreach (KeyValuePair entry in namespaceOrder) { if ( entry.Value < firstNamespaceIndex || ( entry.Value == firstNamespaceIndex && ( firstNamespaceKey == null || string.CompareOrdinal(entry.Key, firstNamespaceKey) < 0 ) ) ) { firstNamespaceKey = entry.Key; firstNamespaceIndex = entry.Value; } } return firstNamespaceKey; } public static Type ResolveSelectedTypeByFullName( IReadOnlyDictionary> typesByNamespace, IReadOnlyDictionary namespaceOrder, string savedNamespaceKey, string savedTypeFullName ) { if (typesByNamespace == null || typesByNamespace.Count == 0) { return null; } if (!string.IsNullOrWhiteSpace(savedTypeFullName)) { Type savedType = NamespaceTypeOrder.FindTypeByFullName( typesByNamespace.Values.SelectMany(types => types ?? Enumerable.Empty()), savedTypeFullName ); if (savedType != null) { return savedType; } } if ( !string.IsNullOrWhiteSpace(savedNamespaceKey) && typesByNamespace.TryGetValue(savedNamespaceKey, out List savedTypes) && savedTypes is { Count: > 0 } ) { return savedTypes[0]; } string firstOrderedNamespaceKey = FindFirstNamespaceKeyByOrder(namespaceOrder); if ( !string.IsNullOrWhiteSpace(firstOrderedNamespaceKey) && typesByNamespace.TryGetValue( firstOrderedNamespaceKey, out List firstOrderedTypes ) && firstOrderedTypes is { Count: > 0 } ) { return firstOrderedTypes[0]; } foreach ( string namespaceKey in (namespaceOrder ?? new Dictionary()) .OrderBy(entry => entry.Value) .ThenBy(entry => entry.Key, StringComparer.Ordinal) .Select(entry => entry.Key) ) { if ( typesByNamespace.TryGetValue(namespaceKey, out List orderedTypes) && orderedTypes is { Count: > 0 } ) { return orderedTypes[0]; } } return typesByNamespace .OrderBy(entry => entry.Key, StringComparer.Ordinal) .Select(entry => entry.Value) .FirstOrDefault(types => types is { Count: > 0 }) ?.FirstOrDefault(); } private void ExpandNamespaceGroupIfNeeded(VisualElement namespaceGroupItem, bool saveState) { if (namespaceGroupItem == null) { return; } Label indicator = namespaceGroupItem.Q