diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index b1dad9f..12e5b67 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,75 +1,88 @@ name: Publish to NPM +# Manual, on-demand publishing (mirrors the DxMessaging release route): dispatch this workflow +# to publish the current package.json version. The publish step is re-runnable (it skips a +# version already on the registry) and attaches build provenance. on: - push: - branches: - - main - paths: - - 'package.json' workflow_dispatch: + inputs: + dry_run: + description: "Pack and report the version/dist-tag without publishing" + type: boolean + default: false + +concurrency: + group: publish-npm + cancel-in-progress: false + +permissions: + contents: read jobs: - publish_npm: + publish: + name: Publish npm package runs-on: ubuntu-latest - + timeout-minutes: 15 + permissions: + contents: read + id-token: write # required for `npm publish --provenance` steps: - - name: Checkout Repository + - name: Checkout uses: actions/checkout@v7 with: - fetch-depth: 0 # Ensure full commit history for version comparison + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@v6 with: - node-version: 18 - registry-url: 'https://registry.npmjs.org/' + node-version: "22" + registry-url: "https://registry.npmjs.org" - - name: Check if version changed - id: version_check + - name: Pack and resolve version / dist-tag + id: pack run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "Manual trigger detected. Skipping version check." - NEW_VERSION=$(jq -r '.version' package.json) - echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV - echo "should_publish=true" >> $GITHUB_ENV - if [[ "$NEW_VERSION" == *"rc"* ]]; then - echo "This is a pre-release (next tag)." - echo "NPM_TAG=next" >> $GITHUB_ENV - else - echo "This is a stable release (latest tag)." - echo "NPM_TAG=latest" >> $GITHUB_ENV - fi + set -euo pipefail + pkg="$(jq -r '.name' package.json)" + ver="$(jq -r '.version' package.json)" + if [ -z "${pkg}" ] || [ "${pkg}" = "null" ] || [ -z "${ver}" ] || [ "${ver}" = "null" ]; then + echo "::error::package.json is missing name or version." + exit 1 + fi + # rc/alpha/beta/preview pre-releases publish under the "next" dist-tag; stable under "latest". + if printf '%s' "${ver}" | grep -qiE '\-(rc|alpha|beta|preview)'; then + npm_tag="next" else - PREV_VERSION=$(git show HEAD~1:package.json | jq -r '.version' || echo "0.0.0") - NEW_VERSION=$(jq -r '.version' package.json) - echo "Previous version: $PREV_VERSION" - echo "New version: $NEW_VERSION" - - if [ "$PREV_VERSION" != "$NEW_VERSION" ]; then - echo "Version changed, proceeding..." - echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV - echo "should_publish=true" >> $GITHUB_ENV - - # Detect pre-releases (versions with "rc" or similar tags) - if [[ "$NEW_VERSION" == *"rc"* ]]; then - echo "This is a pre-release (next tag)." - echo "NPM_TAG=next" >> $GITHUB_ENV - else - echo "This is a stable release (latest tag)." - echo "NPM_TAG=latest" >> $GITHUB_ENV - fi - else - echo "Version did not change, skipping..." - echo "should_publish=false" >> $GITHUB_ENV - fi + npm_tag="latest" fi + package_file="$(npm pack --json | jq -r '.[0].filename')" + { + echo "pkg=${pkg}" + echo "ver=${ver}" + echo "npm_tag=${npm_tag}" + echo "package_file=${package_file}" + } >> "${GITHUB_OUTPUT}" + echo "Prepared ${pkg}@${ver} -> dist-tag '${npm_tag}' (${package_file})." - - name: Install Dependencies - if: env.should_publish == 'true' - run: npm install - - - name: Publish to NPM - if: env.should_publish == 'true' - run: npm publish --access public --tag ${{ env.NPM_TAG }} + - name: Publish to npm with provenance + if: ${{ inputs.dry_run == false }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + PKG: ${{ steps.pack.outputs.pkg }} + VER: ${{ steps.pack.outputs.ver }} + NPM_TAG: ${{ steps.pack.outputs.npm_tag }} + PACKAGE_FILE: ${{ steps.pack.outputs.package_file }} + run: | + set -euo pipefail + # Re-runnable: skip if this exact name@version is already on the registry (a prior run may + # have published it before failing downstream). npm publish is otherwise irreversible. + if npm view "${PKG}@${VER}" version >/dev/null 2>&1; then + echo "::notice::${PKG}@${VER} is already on the registry; skipping publish." + else + npm publish "${PACKAGE_FILE}" --provenance --access public --tag "${NPM_TAG}" + echo "::notice::Published ${PKG}@${VER} to dist-tag '${NPM_TAG}'." + fi + + - name: Dry-run summary + if: ${{ inputs.dry_run == true }} + run: | + echo "Dry run: would publish ${{ steps.pack.outputs.pkg }}@${{ steps.pack.outputs.ver }} to dist-tag '${{ steps.pack.outputs.npm_tag }}'." diff --git a/.gitignore b/.gitignore index efec83d..a9e93e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# This .gitignore file should be placed at the root of your Unity project directory +# This .gitignore file should be placed at the root of your Unity project directory # # Get latest from https://github.com/github/gitignore/blob/main/Unity.gitignore # @@ -72,3 +72,9 @@ crashlytics-build.properties # Temporary auto-generated Android Assets /[Aa]ssets/[Ss]treamingAssets/aa.meta /[Aa]ssets/[Ss]treamingAssets/aa/* + +.vscode/ +.vscode.meta +progress/ +progress.meta +PLAN.md* \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2540fdf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# Repository Guidelines + +## Project Structure & Module Organization +This Unity Package Manager module lives at `Packages/com.wallstop-studios.data-visualizer`. Keep runtime-facing APIs, ScriptableObject base classes, and shared attributes inside `Runtime/` so downstream games can include the package without editor baggage. Place editor windows, UI Toolkit layouts, and menu integrations in `Editor/`. Documentation assets (screens, GIFs, and the `README.md`) stay under `docs/`. EditMode tests live in `Tests/Editor` (`WallstopStudios.DataVisualizer.Tests.Editor.asmdef`); add PlayMode coverage in a sibling `Tests/Runtime` folder mirroring Unity’s layout. + +## Build, Test, and Development Commands +- `unity -projectPath -batchmode -quit -runTests -testPlatform editmode` runs EditMode coverage and surfaces compilation issues headlessly. +- `unity -projectPath -batchmode -quit -runTests -testPlatform playmode` exercises runtime lifecycle hooks before promoting releases. +- `npm pack` (from this directory) generates the `.tgz` artifact consumed by scoped registries or `manifest.json` file references. +- `dotnet tool restore` installs the pinned .NET toolset, and `dotnet tool run csharpier -- format Editor Runtime` applies the CSharpier 1.1.2 style (use `-- check` in CI to fail fast). + +## Coding Style & Naming Conventions +Target C# 10 with 4-space indentation, block-scoped namespaces (the existing style, with `using` directives inside the namespace), and analyzer warnings resolved before review. Follow Unity conventions: ScriptableObjects end in `Data`, `Settings`, or `Profile`, editor windows end in `Window`, and private serialized fields use camelCase names with `[SerializeField]`. Run the repo-pinned formatter (`dotnet tool run csharpier -- format `) on every modified file after `dotnet tool restore`; avoid manual line wrapping. Prefer explicit namespaces so the Data Visualizer window keeps its namespace/type tree predictable. Avoid runtime reflection and stringly-typed lookups, and depend on `nameof` expressions to wire menu items, property paths, and analytics IDs. Note that `InternalsVisibleTo` is not honored for the editor→tests assembly pair in Unity's compilation, so helpers that tests must exercise are `public` (e.g. `ObjectIdExtensions`, `LabelFilterEvaluator`). + +## Testing Guidelines +Leverage Unity Test Framework. Group EditMode specs by feature (`NamespaceOrderingTests`, `SelectionPersistenceTests`) and name methods `Should__When_`. Add PlayMode tests for `BaseDataObject` lifecycle callbacks and asset-state persistence. Gate pull requests on both test suites using the commands above, and aim for coverage on ordering, filtering, and cloning paths before tagging a release. + +## Commit & Pull Request Guidelines +Existing history favors short, imperative subject lines (e.g., “Fix saved object selection when ordering >100”) with the subsystem up front. Reference any related issue IDs in the body. Pull requests must include: summary of behavior change, reproduction/validation steps, screenshots or GIFs for UI tweaks, and a risk callout plus rollback plan. Confirm CSharpier formatting, `npm pack`, and both Unity test commands before requesting review. diff --git a/progress/session-001-issue-12-entityid-fix.md.meta b/AGENTS.md.meta similarity index 75% rename from progress/session-001-issue-12-entityid-fix.md.meta rename to AGENTS.md.meta index fc36166..3453319 100644 --- a/progress/session-001-issue-12-entityid-fix.md.meta +++ b/AGENTS.md.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 436edde5595c606438412d61d262148f +guid: d847cfddeea41c695b15d9fdf312f48c TextScriptImporter: externalObjects: {} userData: diff --git a/Editor/DataVisualizer/Data/LabelFilterEvaluator.cs b/Editor/DataVisualizer/Data/LabelFilterEvaluator.cs new file mode 100644 index 0000000..99cc80f --- /dev/null +++ b/Editor/DataVisualizer/Data/LabelFilterEvaluator.cs @@ -0,0 +1,88 @@ +namespace WallstopStudios.DataVisualizer.Editor.Data +{ + using System; + using System.Collections.Generic; + + /// + /// Pure evaluation of a against an object's Unity asset + /// labels. The AND/OR combination rules previously lived in two copies (the live filter loop and + /// a dead helper) which drifted apart; consolidating them here fixes that and lets the rules be + /// unit tested without an editor window or real assets. + /// + /// Public because the package's editor test assembly cannot see internals of the editor + /// assembly (InternalsVisibleTo is not honored for that pair in Unity's compilation). + /// + public static class LabelFilterEvaluator + { + /// + /// Returns whether satisfy . An + /// empty AND or OR clause is treated as "no constraint". In OR mode an object must satisfy at + /// least one active clause; an empty clause never counts as a match — otherwise every + /// object would pass whenever only the other clause was populated (the historical bug where + /// OR-mode with only OR labels matched everything). + /// + public static bool Matches(IReadOnlyList objectLabels, TypeLabelFilterConfig config) + { + if (config == null) + { + return true; + } + + List andLabels = config.andLabels; + List orLabels = config.orLabels; + bool hasAnd = andLabels is { Count: > 0 }; + bool hasOr = orLabels is { Count: > 0 }; + if (!hasAnd && !hasOr) + { + return true; + } + + IReadOnlyList present = objectLabels ?? Array.Empty(); + bool andSatisfied = hasAnd && AllPresent(andLabels, present); + bool orSatisfied = hasOr && AnyPresent(orLabels, present); + + return config.combinationType == LabelCombinationType.Or + ? andSatisfied || orSatisfied // at least one ACTIVE clause must match + : (!hasAnd || andSatisfied) && (!hasOr || orSatisfied); // every active clause + + // Local, non-capturing helpers keep this allocation-free: Matches runs once per object + // during filtering, so a per-call HashSet or closure would add real GC pressure. Asset + // label sets are tiny, so linear membership over the provided list is cheap. + static bool AllPresent(List required, IReadOnlyList labels) + { + for (int i = 0; i < required.Count; i++) + { + if (!Contains(labels, required[i])) + { + return false; + } + } + return true; + } + + static bool AnyPresent(List candidates, IReadOnlyList labels) + { + for (int i = 0; i < candidates.Count; i++) + { + if (Contains(labels, candidates[i])) + { + return true; + } + } + return false; + } + + static bool Contains(IReadOnlyList labels, string target) + { + for (int i = 0; i < labels.Count; i++) + { + if (string.Equals(labels[i], target, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + } + } +} diff --git a/Editor/DataVisualizer/Data/LabelFilterEvaluator.cs.meta b/Editor/DataVisualizer/Data/LabelFilterEvaluator.cs.meta new file mode 100644 index 0000000..0a7ca66 --- /dev/null +++ b/Editor/DataVisualizer/Data/LabelFilterEvaluator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6ef79f4c7907ddf4d817b015aa47c94a \ No newline at end of file diff --git a/Editor/DataVisualizer/DataVisualizer.cs b/Editor/DataVisualizer/DataVisualizer.cs index d43c4b3..4710861 100644 --- a/Editor/DataVisualizer/DataVisualizer.cs +++ b/Editor/DataVisualizer/DataVisualizer.cs @@ -64,14 +64,24 @@ public sealed class DataVisualizer : EditorWindow private const string SearchPlaceholder = "Search..."; private const int MaxSearchResults = 25; - private const float DefaultOuterSplitWidth = 200f; + private const float DefaultOuterSplitWidth = 350f; private const float DefaultInnerSplitWidth = 250f; - private const int MaxObjectsPerPage = 100; + 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, - Object = 1, Namespace = 2, Type = 3, } @@ -169,9 +179,6 @@ private int HiddenNamespaces private readonly Dictionary _namespaceOrder = new(StringComparer.Ordinal); - private readonly Dictionary _objectVisualElementMap = - new(); - [Obsolete("Use HiddenNamespaces property instead")] private int _hiddenNamespaces; @@ -183,26 +190,36 @@ private int HiddenNamespaces private readonly NamespaceController _namespaceController; private ScriptableObject _selectedObject; - private VisualElement _selectedElement; private VisualElement _selectedNamespaceElement; private VisualElement _namespaceListContainer; - private VisualElement _objectPageController; - private Button _previousPageButton; - private Button _nextPageButton; - private IntegerField _currentPageField; - private IntegerField _maxPageField; - private VisualElement _objectListContainer; private VisualElement _inspectorContainer; - private ScrollView _objectScrollView; 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; @@ -264,6 +281,14 @@ private int HiddenNamespaces 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; @@ -314,6 +339,40 @@ private int HiddenNamespaces 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; @@ -331,6 +390,7 @@ 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) @@ -338,8 +398,8 @@ public static void ShowWindow() return; } - float width = Mathf.Max(800, window.position.width); - float height = Mathf.Max(400, window.position.height); + 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; @@ -354,12 +414,11 @@ public static void ShowWindow() private void OnEnable() { + minSize = new Vector2(MinWindowWidth, MinWindowHeight); _nextColorIndex = 0; Instance = this; _isSearchCachePopulated = false; - _objectVisualElementMap.Clear(); _selectedObject = null; - _selectedElement = null; _selectedObjects.Clear(); #if ODIN_INSPECTOR _odinPropertyTree = null; @@ -389,19 +448,12 @@ private void OnEnable() _allDataProcessors.Sort((lhs, rhs) => string.CompareOrdinal(lhs.Name, rhs.Name)); - LoadScriptableObjectTypes(); + // Don't load types here - it blocks the UI from appearing + // LoadScriptableObjectTypes() is now deferred to CreateGUI rootVisualElement.RegisterCallback( HandleGlobalKeyDown, TrickleDown.TrickleDown ); - rootVisualElement - .schedule.Execute(() => - { - PopulateSearchCache(); - RestorePreviousSelection(); - StartPeriodicWidthSave(); - }) - .ExecuteLater(10); } private void OnDisable() @@ -425,8 +477,17 @@ private void Cleanup() 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; - _selectedElement = null; _selectedObject = null; _scriptableObjectTypes.Clear(); _namespaceOrder.Clear(); @@ -467,51 +528,171 @@ private void Cleanup() 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)) + if (uniqueGuids.Add(guid)) { - continue; + _pendingSearchCacheGuids.Enqueue(guid); } + } + } - string path = AssetDatabase.GUIDToAssetPath(guid); - if (!string.IsNullOrWhiteSpace(path)) - { - ScriptableObject obj = - AssetDatabase.LoadMainAssetAtPath(path) as ScriptableObject; + // 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 (obj != null && obj.GetType() == type) - { - _allManagedObjectsCache.Add(obj); - } - } - } + 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 } - _allManagedObjectsCache.Sort( - (a, b) => + 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)) { - int comparison = string.Compare(a.name, b.name, StringComparison.Ordinal); - if (comparison != 0) + 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())) { - return comparison; + loadedObjects.Add(obj); } - - return string.Compare( - a.GetType().FullName, - b.GetType().FullName, - StringComparison.Ordinal - ); } - ); + } + + // 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(); + } + } - _isSearchCachePopulated = true; + // 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() @@ -637,13 +818,26 @@ private void RefreshAllViews() } } - if (selectedType != null) + // 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) { - LoadObjectTypes(selectedType); + _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; @@ -671,11 +865,6 @@ private void RefreshAllViews() }); } - if (selectedObject == null) - { - selectedObject = _selectedObjects.FirstOrDefault(); - } - PopulateSearchCache(); BuildNamespaceView(); BuildObjectsView(); @@ -690,7 +879,14 @@ private void RefreshAllViews() } } - SelectObject(selectedObject); + // 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; } @@ -820,8 +1016,14 @@ private void CheckAndSaveSplitterWidths() return; } - float currentOuterWidth = _namespaceColumnElement.resolvedStyle.width; - float currentInnerWidth = _objectColumnElement.resolvedStyle.width; + float currentOuterWidth = Mathf.Max( + _namespaceColumnElement.resolvedStyle.width, + MinNamespacePaneWidth + ); + float currentInnerWidth = Mathf.Max( + _objectColumnElement.resolvedStyle.width, + MinObjectPaneWidth + ); if (!Mathf.Approximately(currentOuterWidth, _lastSavedOuterWidth)) { @@ -894,9 +1096,16 @@ private void RestorePreviousSelection() } selectedType ??= typesInNamespace[0]; - LoadObjectTypes(selectedType); + + // Build namespace view first so type selection is visible BuildNamespaceView(); - BuildObjectsView(); + + // Register the restored type via SelectType, which sets the selected type BEFORE loading. + // That way the load's synchronous saved/first-object selection doesn't re-enter SelectType + // (which would start a second load), the object list filters correctly for the restored + // type, and an empty type shows its empty view rather than a stale one. SelectType also + // loads the objects, restores the saved selection, and refreshes the dependent UI. + _namespaceController.SelectType(this, selectedType); VisualElement typeElementToSelect = FindTypeElement(selectedType); if (typeElementToSelect != null) @@ -931,47 +1140,6 @@ private void RestorePreviousSelection() } } } - - string savedObjectGuid = null; - if (selectedType != null) - { - savedObjectGuid = GetLastSelectedObjectGuidForType(selectedType.FullName); - } - - ScriptableObject objectToSelect = null; - - if (!string.IsNullOrWhiteSpace(savedObjectGuid) && 0 < _selectedObjects.Count) - { - objectToSelect = _selectedObjects.Find(obj => - { - if (obj == null) - { - return false; - } - - string path = AssetDatabase.GetAssetPath(obj); - return !string.IsNullOrWhiteSpace(path) - && string.Equals( - AssetDatabase.AssetPathToGUID(path), - savedObjectGuid, - StringComparison.OrdinalIgnoreCase - ); - }); - } - - if (objectToSelect == null && 0 < _selectedObjects.Count) - { - objectToSelect = _selectedObjects[0]; - } - - SelectObject(objectToSelect); - if (objectToSelect == null) - { - _namespaceController.SelectType(this, selectedType); - } - UpdateCreateObjectButtonStyle(); - BuildProcessorColumnView(); - UpdateLabelAreaAndFilter(); } private VisualElement FindTypeElement(Type targetType) @@ -1055,13 +1223,13 @@ public void CreateGUI() _searchField.RegisterCallback(HandleSearchKeyDown); headerRow.Add(_searchField); - float initialOuterWidth = EditorPrefs.GetFloat( - PrefsSplitterOuterKey, - DefaultOuterSplitWidth + float initialOuterWidth = Mathf.Max( + EditorPrefs.GetFloat(PrefsSplitterOuterKey, DefaultOuterSplitWidth), + MinNamespacePaneWidth ); - float initialInnerWidth = EditorPrefs.GetFloat( - PrefsSplitterInnerKey, - DefaultInnerSplitWidth + float initialInnerWidth = Mathf.Max( + EditorPrefs.GetFloat(PrefsSplitterInnerKey, DefaultInnerSplitWidth), + MinObjectPaneWidth ); _lastSavedOuterWidth = initialOuterWidth; @@ -1141,10 +1309,46 @@ public void CreateGUI() _confirmNamespaceAddPopover = CreatePopoverBase("confirm-namespace-add-popover"); root.Add(_confirmNamespaceAddPopover); - BuildNamespaceView(); - BuildProcessorColumnView(); - BuildObjectsView(); - BuildInspectorView(); + // CreateGUI is now complete - window structure is ready + // Defer ALL content building to next frame so window appears instantly + rootVisualElement + .schedule.Execute(() => + { + if (EnableAsyncLoadDebugLog) + { + Debug.Log( + $"[DataVisualizer] CreateGUI - Loading types and building views at {System.DateTime.Now:HH:mm:ss.fff}" + ); + } + + // Load types (fast now without CreateInstance) + LoadScriptableObjectTypes(); + + // Build all views - window is already visible at this point + BuildNamespaceView(); + BuildProcessorColumnView(); + BuildObjectsView(); + BuildInspectorView(); + + // Schedule the async initialization after views are built + rootVisualElement + .schedule.Execute(() => + { + if (EnableAsyncLoadDebugLog) + { + Debug.Log( + $"[DataVisualizer] CreateGUI - Starting async initialization at {System.DateTime.Now:HH:mm:ss.fff}" + ); + } + // Start async search cache population in background (low priority) + PopulateSearchCacheAsync(); + // Restore selection with priority async loading + RestorePreviousSelection(); + StartPeriodicWidthSave(); + }) + .ExecuteLater(10); + }) + .ExecuteLater(1); // Execute on next frame so window renders first } private static void TryLoadStyleSheet(VisualElement root) @@ -1156,125 +1360,247 @@ private static void TryLoadStyleSheet(VisualElement root) ); if (!string.IsNullOrWhiteSpace(packageRoot)) { - if ( - packageRoot.StartsWith("Packages", StringComparison.OrdinalIgnoreCase) - && !packageRoot.Contains(PackageId, StringComparison.OrdinalIgnoreCase) - ) + // Convert absolute path to Unity relative path if needed + string packagePath = null; + bool isAbsolutePath = Path.IsPathRooted(packageRoot); + + if (isAbsolutePath) { - int dataVisualizerIndex = packageRoot.LastIndexOf( - "DataVisualizer", - StringComparison.Ordinal - ); - if (0 <= dataVisualizerIndex) + // Convert absolute path to Unity relative path + string projectPath = Path.GetDirectoryName(Application.dataPath); + string normalizedProjectPath = projectPath.Replace('\\', '/'); + string normalizedPackageRoot = packageRoot.Replace('\\', '/'); + + if ( + normalizedPackageRoot.StartsWith( + normalizedProjectPath, + StringComparison.OrdinalIgnoreCase + ) + ) { - packageRoot = packageRoot[..dataVisualizerIndex]; - packageRoot += PackageId; + // Extract the part after project root + string relativePart = normalizedPackageRoot + .Substring(normalizedProjectPath.Length) + .TrimStart('/'); + packagePath = relativePart; + } + else + { + // Not within project, try to find Packages folder in path + int packagesIndex = normalizedPackageRoot.IndexOf( + "/Packages/", + StringComparison.OrdinalIgnoreCase + ); + if (packagesIndex >= 0) + { + packagePath = normalizedPackageRoot.Substring(packagesIndex + 1); // +1 to skip the leading slash so the path starts at "Packages/" + } + else + { + // Just try to extract the folder name + string folderName = Path.GetFileName(packageRoot); + if ( + !string.IsNullOrWhiteSpace(folderName) + && folderName.Contains( + "DataVisualizer", + StringComparison.OrdinalIgnoreCase + ) + ) + { + packagePath = $"Packages/{folderName}"; + } + } } } - - char pathSeparator = Path.DirectorySeparatorChar; - string styleSheetPath = - $"{packageRoot}{pathSeparator}Editor{pathSeparator}DataVisualizer{pathSeparator}Styles{pathSeparator}DataVisualizerStyles.uss"; - string unityRelativeStyleSheetPath = DirectoryHelper.AbsoluteToUnityRelativePath( - styleSheetPath - ); - unityRelativeStyleSheetPath = unityRelativeStyleSheetPath.SanitizePath(); - - const string packageCache = "PackageCache/"; - int packageCacheIndex; - if (!string.IsNullOrWhiteSpace(unityRelativeStyleSheetPath)) + else { - styleSheet = AssetDatabase.LoadAssetAtPath( - unityRelativeStyleSheetPath - ); + // Already a Unity relative path + packagePath = packageRoot.Replace('\\', '/'); } - if (styleSheet == null && !string.IsNullOrWhiteSpace(unityRelativeStyleSheetPath)) + // Ensure it starts with Packages/ + if (!string.IsNullOrWhiteSpace(packagePath)) { - packageCacheIndex = unityRelativeStyleSheetPath.IndexOf( - packageCache, - StringComparison.OrdinalIgnoreCase - ); - if (0 <= packageCacheIndex) + if (!packagePath.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase)) { - unityRelativeStyleSheetPath = unityRelativeStyleSheetPath[ - (packageCacheIndex + packageCache.Length).. - ]; - int forwardIndex = unityRelativeStyleSheetPath.IndexOf( - "/", - StringComparison.Ordinal - ); - if (0 <= forwardIndex) + // Extract package folder name + string folderName = Path.GetFileName(packagePath); + if (string.IsNullOrWhiteSpace(folderName)) { - unityRelativeStyleSheetPath = unityRelativeStyleSheetPath.Substring( - forwardIndex - ); - unityRelativeStyleSheetPath = - "Packages/" + PackageId + "/" + unityRelativeStyleSheetPath; + folderName = Path.GetFileName(packageRoot); } - else + if (!string.IsNullOrWhiteSpace(folderName)) { - unityRelativeStyleSheetPath = "Packages/" + unityRelativeStyleSheetPath; + packagePath = $"Packages/{folderName}"; } } - if (!string.IsNullOrWhiteSpace(unityRelativeStyleSheetPath)) + // Try direct Unity relative paths + string[] styleSheetPathsToTry = new[] { - styleSheet = AssetDatabase.LoadAssetAtPath( - unityRelativeStyleSheetPath - ); - if (styleSheet == null) + $"{packagePath}/Editor/DataVisualizer/Styles/DataVisualizerStyles.uss", + $"Packages/{PackageId}/Editor/DataVisualizer/Styles/DataVisualizerStyles.uss", + }; + + foreach (string styleSheetPath in styleSheetPathsToTry) + { + styleSheet = AssetDatabase.LoadAssetAtPath(styleSheetPath); + if (styleSheet != null) { - Debug.LogError( - $"Failed to load Data Visualizer style sheet (package root: '{packageRoot}'), relative path '{unityRelativeStyleSheetPath}'." - ); + break; } } - else + + string[] fontPathsToTry = new[] { - Debug.LogError( - $"Failed to convert absolute path '{styleSheetPath}' to Unity relative path." - ); + $"{packagePath}/Editor/Fonts/IBMPlexMono-Regular.ttf", + $"Packages/{PackageId}/Editor/Fonts/IBMPlexMono-Regular.ttf", + }; + + foreach (string fontPath in fontPathsToTry) + { + font = AssetDatabase.LoadAssetAtPath(fontPath); + if (font != null) + { + break; + } } } - string fontPath = - $"{packageRoot}{pathSeparator}Editor{pathSeparator}Fonts{pathSeparator}IBMPlexMono-Regular.ttf"; - string unityRelativeFontPath = DirectoryHelper.AbsoluteToUnityRelativePath( - fontPath - ); - - font = AssetDatabase.LoadAssetAtPath(unityRelativeFontPath); - if (font == null) + // Fallback to absolute path conversion if direct paths didn't work + if (styleSheet == null || font == null) { - packageCacheIndex = unityRelativeFontPath.IndexOf( - packageCache, - StringComparison.OrdinalIgnoreCase - ); - if (0 <= packageCacheIndex) + if ( + packageRoot.StartsWith("Packages", StringComparison.OrdinalIgnoreCase) + && !packageRoot.Contains(PackageId, StringComparison.OrdinalIgnoreCase) + ) { - unityRelativeFontPath = unityRelativeFontPath[ - (packageCacheIndex + packageCache.Length).. - ]; - int forwardIndex = unityRelativeFontPath.IndexOf( - "/", + int dataVisualizerIndex = packageRoot.LastIndexOf( + "DataVisualizer", StringComparison.Ordinal ); - if (0 <= forwardIndex) + if (0 <= dataVisualizerIndex) { - unityRelativeFontPath = unityRelativeFontPath.Substring(forwardIndex); - unityRelativeFontPath = - "Packages/" + PackageId + "/" + unityRelativeFontPath; + packageRoot = packageRoot[..dataVisualizerIndex]; + packageRoot += PackageId; } - else + } + + char pathSeparator = Path.DirectorySeparatorChar; + if (styleSheet == null) + { + string styleSheetPath = + $"{packageRoot}{pathSeparator}Editor{pathSeparator}DataVisualizer{pathSeparator}Styles{pathSeparator}DataVisualizerStyles.uss"; + string unityRelativeStyleSheetPath = + DirectoryHelper.AbsoluteToUnityRelativePath(styleSheetPath); + unityRelativeStyleSheetPath = unityRelativeStyleSheetPath.SanitizePath(); + + const string packageCache = "PackageCache/"; + int packageCacheIndex; + if (!string.IsNullOrWhiteSpace(unityRelativeStyleSheetPath)) { - unityRelativeFontPath = "Packages/" + unityRelativeFontPath; + styleSheet = AssetDatabase.LoadAssetAtPath( + unityRelativeStyleSheetPath + ); + } + + if ( + styleSheet == null + && !string.IsNullOrWhiteSpace(unityRelativeStyleSheetPath) + ) + { + packageCacheIndex = unityRelativeStyleSheetPath.IndexOf( + packageCache, + StringComparison.OrdinalIgnoreCase + ); + if (0 <= packageCacheIndex) + { + unityRelativeStyleSheetPath = unityRelativeStyleSheetPath[ + (packageCacheIndex + packageCache.Length).. + ]; + int forwardIndex = unityRelativeStyleSheetPath.IndexOf( + "/", + StringComparison.Ordinal + ); + if (0 <= forwardIndex) + { + unityRelativeStyleSheetPath = + unityRelativeStyleSheetPath.Substring(forwardIndex); + unityRelativeStyleSheetPath = + "Packages/" + PackageId + "/" + unityRelativeStyleSheetPath; + } + else + { + unityRelativeStyleSheetPath = + "Packages/" + unityRelativeStyleSheetPath; + } + } + + if (!string.IsNullOrWhiteSpace(unityRelativeStyleSheetPath)) + { + styleSheet = AssetDatabase.LoadAssetAtPath( + unityRelativeStyleSheetPath + ); + if (styleSheet == null) + { + Debug.LogError( + $"Failed to load Data Visualizer style sheet (package root: '{packageRoot}'), relative path '{unityRelativeStyleSheetPath}'." + ); + } + } + else + { + Debug.LogError( + $"Failed to convert absolute path '{styleSheetPath}' to Unity relative path." + ); + } } } - if (!string.IsNullOrWhiteSpace(unityRelativeFontPath)) + if (font == null) { + string fontPath = + $"{packageRoot}{pathSeparator}Editor{pathSeparator}Fonts{pathSeparator}IBMPlexMono-Regular.ttf"; + string unityRelativeFontPath = DirectoryHelper.AbsoluteToUnityRelativePath( + fontPath + ); + font = AssetDatabase.LoadAssetAtPath(unityRelativeFontPath); + if (font == null && !string.IsNullOrWhiteSpace(unityRelativeFontPath)) + { + const string packageCache = "PackageCache/"; + int packageCacheIndex = unityRelativeFontPath.IndexOf( + packageCache, + StringComparison.OrdinalIgnoreCase + ); + if (0 <= packageCacheIndex) + { + unityRelativeFontPath = unityRelativeFontPath[ + (packageCacheIndex + packageCache.Length).. + ]; + int forwardIndex = unityRelativeFontPath.IndexOf( + "/", + StringComparison.Ordinal + ); + if (0 <= forwardIndex) + { + unityRelativeFontPath = unityRelativeFontPath.Substring( + forwardIndex + ); + unityRelativeFontPath = + "Packages/" + PackageId + "/" + unityRelativeFontPath; + } + else + { + unityRelativeFontPath = "Packages/" + unityRelativeFontPath; + } + } + + if (!string.IsNullOrWhiteSpace(unityRelativeFontPath)) + { + font = AssetDatabase.LoadAssetAtPath(unityRelativeFontPath); + } + } } } } @@ -1544,6 +1870,20 @@ private void RunDataProcessor(VisualElement context, IDataProcessor processor) return; } + // The type's objects still stream in asynchronously; running a processor now would only + // touch the already-loaded subset while the dialog implies the whole type. Wait for the + // load to finish so the processor operates on the complete set. + if (_isLoadingObjectsAsync && _asyncLoadTargetType == _namespaceController.SelectedType) + { + EditorUtility.DisplayDialog( + "Loading In Progress", + $"'{NamespaceController.GetTypeDisplayName(_namespaceController.SelectedType)}' is still loading its " + + "objects. Please wait for loading to finish before running a processor so it operates on the full set.", + "OK" + ); + return; + } + ScriptableObject[] toProcess; switch (state.logic) @@ -1785,12 +2125,38 @@ private void PerformSearch(string searchText) _currentSearchResultItems.Clear(); _searchHighlightIndex = -1; - if (!_isSearchCachePopulated || string.IsNullOrWhiteSpace(searchText)) + if (string.IsNullOrWhiteSpace(searchText)) { CloseActivePopover(); return; } + if (!_isSearchCachePopulated) + { + // If the cache isn't populated and isn't already (re)building — e.g. after the window + // was hidden and shown again, which clears the cache without rebuilding it (population + // otherwise only runs once in CreateGUI) — kick it off now so this query resolves. + if (!_isLoadingSearchCacheAsync) + { + PopulateSearchCacheAsync(); + } + + // Keep the popover open with a status line instead of dismissing the user's query; + // RefreshActiveSearch re-runs this search automatically once the cache is ready. + Label buildingLabel = new("Building search index…") + { + style = + { + unityTextAlign = TextAnchor.MiddleCenter, + paddingTop = 8, + paddingBottom = 8, + }, + }; + _searchPopover.Add(buildingLabel); + OpenPopover(_searchPopover, _searchField, shouldFocus: false); + return; + } + string[] searchTerms = searchText.Split( new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries @@ -2272,11 +2638,43 @@ private void NavigateToObject(ScriptableObject targetObject) Type targetType = targetObject.GetType(); bool typeChanged = _namespaceController.SelectedType != targetType; - _namespaceController.SelectType(this, targetType); if (typeChanged) { - LoadObjectTypes(targetType); + // Persist the navigation target as this type's last selection so the async loader + // both prioritizes loading it and auto-selects it once its batch is in. SelectType + // already starts LoadObjectTypesAsync; loading again here (as before) cleared + // _selectedObjects mid-flight, interleaved duplicate batches, and left the loader to + // auto-select the *previous* saved object instead of the one the user clicked. + string targetGuid = AssetDatabase.AssetPathToGUID( + AssetDatabase.GetAssetPath(targetObject) + ); + if (!string.IsNullOrWhiteSpace(targetGuid)) + { + SetLastSelectedObjectGuidForType(targetType.FullName, targetGuid); + } + + _namespaceController.SelectType(this, targetType); + CloseActivePopover(); + return; + } + + if (!_selectedObjects.Contains(targetObject)) + { + // Same type, but the target's batch has not streamed in yet. Persist it as this type's + // last selection and reload with it prioritized so it loads first and is auto-selected — + // otherwise SelectObject would update the inspector while its ListView row is missing. + string targetGuid = AssetDatabase.AssetPathToGUID( + AssetDatabase.GetAssetPath(targetObject) + ); + if (!string.IsNullOrWhiteSpace(targetGuid)) + { + SetLastSelectedObjectGuidForType(targetType.FullName, targetGuid); + } + + LoadObjectTypesAsync(targetType, priorityLoad: false); + CloseActivePopover(); + return; } BuildObjectsView(); @@ -3378,7 +3776,7 @@ private void HandleCreateConfirmed(Type type, TextField nameField, Label errorLa return; } - ScriptableObject instance = CreateInstance(type); + ScriptableObject instance = ScriptableObject.CreateInstance(type); if (instance is ICreatable creatable) { creatable.BeforeCreate(); @@ -3394,6 +3792,19 @@ private void HandleCreateConfirmed(Type type, TextField nameField, Label errorLa CloseActivePopover(); if (type == _namespaceController.SelectedType) { + if (_isLoadingObjectsAsync) + { + // A load is still streaming assets in by canonical order. Give the new instance an + // order past all of them so it sorts to the end and the loader's binary-search + // inserts stay consistent (they assume _selectedObjects is ordered by this index). + _selectedObjectOrderIndex[instance] = int.MaxValue; + string instanceGuid = AssetDatabase.AssetPathToGUID(uniquePath); + if (!string.IsNullOrWhiteSpace(instanceGuid)) + { + _asyncDisplayOrderByGuid[instanceGuid] = int.MaxValue; + } + } + _selectedObjects.Add(instance); BuildObjectsView(); } @@ -3581,7 +3992,9 @@ private void HandleDeleteConfirmed() int index = _selectedObjects.IndexOf(objectToDelete); _selectedObjects.Remove(objectToDelete); _selectedObjects.RemoveAll(obj => obj == null); - _objectVisualElementMap.Remove(objectToDelete, out VisualElement visualElement); + _filteredObjects.Remove(objectToDelete); + _filteredObjects.RemoveAll(obj => obj == null); + _selectedObjectOrderIndex.Remove(objectToDelete); int targetIndex = _selectedObject == objectToDelete ? Mathf.Max(0, index - 1) : 0; bool deleted = AssetDatabase.DeleteAsset(path); @@ -3589,15 +4002,7 @@ private void HandleDeleteConfirmed() { Debug.Log($"Asset '{path}' deleted successfully."); AssetDatabase.Refresh(); - VisualElement parent = visualElement?.parent; - visualElement?.RemoveFromHierarchy(); - if (parent != null) - { - foreach (VisualElement child in parent.Children()) - { - NamespaceController.RecalibrateVisualElements(child, offset: 1); - } - } + BuildObjectsView(); if (targetIndex < _selectedObjects.Count) { SelectObject(_selectedObjects[targetIndex]); @@ -3608,7 +4013,6 @@ private void HandleDeleteConfirmed() } else { - _emptyObjectLabel.style.display = DisplayStyle.Flex; SelectObject(null); } } @@ -3660,6 +4064,8 @@ private VisualElement CreateNamespaceColumn() borderRightWidth = 1, borderRightColor = Color.gray, height = Length.Percent(100), + minWidth = MinNamespacePaneWidth, + flexShrink = 0, }, }; @@ -4405,13 +4811,37 @@ private VisualElement CreateObjectColumn() borderRightColor = Color.gray, flexDirection = FlexDirection.Column, height = Length.Percent(100), + minWidth = MinObjectPaneWidth, + flexShrink = 0, }, }; VisualElement objectHeader = new() { name = "object-header" }; objectHeader.AddToClassList("object-header"); - objectHeader.Add(new Label("Objects")); + VisualElement headerLeft = new() + { + style = + { + flexDirection = FlexDirection.Row, + alignItems = Align.Center, + flexGrow = 1, + }, + }; + + headerLeft.Add(new Label("Objects")); + + _objectLoadingIndicator = new Label() { name = "object-loading-indicator", text = "" }; + _objectLoadingIndicator.AddToClassList("loading-indicator"); + _objectLoadingIndicator.style.display = DisplayStyle.None; + _objectLoadingIndicator.style.marginLeft = 8; + _objectLoadingIndicator.style.fontSize = 11; + _objectLoadingIndicator.style.unityFontStyleAndWeight = FontStyle.Italic; + _objectLoadingIndicator.style.color = new Color(0.7f, 0.7f, 0.7f); + headerLeft.Add(_objectLoadingIndicator); + + objectHeader.Add(headerLeft); + _createObjectButton = null; _createObjectButton = new Button(() => { @@ -4614,76 +5044,44 @@ private VisualElement CreateObjectColumn() SetupDropTarget(_availableLabelsContainer, LabelFilterSection.Available); SetupDropTarget(_andLabelsContainer, LabelFilterSection.AND); - SetupDropTarget(_orLabelsContainer, LabelFilterSection.OR); - - _objectPageController = new VisualElement - { - name = "object-page-controller", - style = { display = DisplayStyle.None }, - }; - _objectPageController.AddToClassList("object-page-controller"); - _previousPageButton = new Button(() => - { - int currentPage = GetCurrentPage(_namespaceController.SelectedType); - if (currentPage <= 0) - { - return; - } - - SetCurrentPage(_namespaceController.SelectedType, currentPage - 1); - BuildObjectsView(); - }) - { - text = "←", - }; - _previousPageButton.AddToClassList("go-button-disabled"); - - _currentPageField = new IntegerField(); - _currentPageField.AddToClassList("current-page-field"); - _currentPageField.RegisterValueChangedCallback(evt => - { - int newValue = evt.newValue; - newValue = Mathf.Clamp(newValue, 0, _filteredObjects.Count / MaxObjectsPerPage); - if (newValue != evt.newValue) - { - _currentPageField.SetValueWithoutNotify(newValue); - } - - SetCurrentPage(_namespaceController.SelectedType, newValue); - BuildObjectsView(); - }); - _maxPageField = new IntegerField() { isReadOnly = true }; - _maxPageField.AddToClassList("max-page-field"); - _nextPageButton = new Button(() => - { - int currentPage = GetCurrentPage(_namespaceController.SelectedType); - if (_filteredObjects.Count / MaxObjectsPerPage <= currentPage) - { - return; - } + SetupDropTarget(_orLabelsContainer, LabelFilterSection.OR); - SetCurrentPage(_namespaceController.SelectedType, currentPage + 1); - BuildObjectsView(); - }) + _emptyObjectLabel = new Label { - text = "→", + name = "empty-object-list-label", + style = { alignSelf = Align.Center, display = DisplayStyle.None }, }; - _nextPageButton.AddToClassList("go-button-disabled"); - _objectPageController.Add(_previousPageButton); - _objectPageController.Add(_currentPageField); - _objectPageController.Add(_maxPageField); - _objectPageController.Add(_nextPageButton); - - objectColumn.Add(_objectPageController); - - _objectScrollView = new ScrollView(ScrollViewMode.Vertical) + _emptyObjectLabel.AddToClassList("empty-object-list-label"); + objectColumn.Add(_emptyObjectLabel); + + _objectListView = new ListView + { + name = "object-listview", + itemsSource = _filteredObjects, + selectionType = SelectionType.Single, + virtualizationMethod = CollectionVirtualizationMethod.FixedHeight, + fixedItemHeight = ObjectRowFixedHeight, + makeItem = MakeObjectRow, + unbindItem = (element, _) => element.userData = null, + reorderable = true, + // Animated gives the "floaty" reorder feel (rows slide to make room). Its drag-handle + // column is hidden via USS (.unity-list-view__reorderable-handle) so it doesn't add the + // left gutter the user flagged; the whole row stays draggable. + reorderMode = ListViewReorderMode.Animated, + showBorder = false, + style = { flexGrow = 1 }, + }; + _objectListView.bindItem = (element, i) => { - name = "object-scrollview", + if (i >= 0 && i < _filteredObjects.Count) + { + BindObjectRow(element, _filteredObjects[i], i); + } }; - _objectScrollView.AddToClassList("object-scrollview"); - _objectListContainer = new VisualElement { name = "object-list" }; - _objectScrollView.Add(_objectListContainer); - objectColumn.Add(_objectScrollView); + _objectListView.AddToClassList("object-scrollview"); + _objectListView.RegisterSelectionChangedCompat(OnObjectListSelectionChanged); + _objectListView.itemIndexChanged += OnObjectListItemReordered; + objectColumn.Add(_objectListView); UpdateCreateObjectButtonStyle(); UpdateLabelAreaAndFilter(); return objectColumn; @@ -4806,7 +5204,13 @@ private VisualElement CreateInspectorColumn() VisualElement inspectorColumn = new() { name = "inspector-column", - style = { flexGrow = 1, height = Length.Percent(100) }, + style = + { + flexGrow = 1, + height = Length.Percent(100), + minWidth = MinInspectorPaneWidth, + flexShrink = 0, + }, }; _inspectorScrollView = new ScrollView(ScrollViewMode.Vertical) { @@ -5312,45 +5716,14 @@ private void ApplyLabelFilter(bool buildObjectsView = true) } else { - HashSet uniqueLabels = new(StringComparer.Ordinal); - Predicate labelMatch = uniqueLabels.Contains; foreach (ScriptableObject obj in _selectedObjects) { - if (obj == null) - { - continue; - } - - string[] labels = AssetDatabase.GetLabels(obj); - uniqueLabels.Clear(); - foreach (string label in labels) - { - uniqueLabels.Add(label); - } - - bool matchesAnd = noAndFilter || andLabels.TrueForAll(labelMatch); - bool matchesOr = noOrFilter || orLabels.Exists(labelMatch); - - switch (config.combinationType) + if ( + obj != null + && LabelFilterEvaluator.Matches(AssetDatabase.GetLabels(obj), config) + ) { - case LabelCombinationType.And: - { - if (matchesAnd && matchesOr) - { - _filteredObjects.Add(obj); - } - - break; - } - case LabelCombinationType.Or: - { - if (matchesAnd || matchesOr) - { - _filteredObjects.Add(obj); - } - - break; - } + _filteredObjects.Add(obj); } } } @@ -5760,200 +6133,118 @@ private void BuildNamespaceView() internal void BuildObjectsView() { _selectedObjects.RemoveAll(obj => obj == null); - if (_objectListContainer == null) + if (_objectListView == null) { return; } - _objectListContainer.Clear(); - _objectVisualElementMap.Clear(); - _objectScrollView.scrollOffset = Vector2.zero; - Type selectedType = _namespaceController.SelectedType; - _emptyObjectLabel = new Label( - $"No objects of type '{selectedType?.Name}' found.\nUse the '+' button above to create one." - ) - { - name = "empty-object-list-label", - style = { alignSelf = Align.Center }, - }; - _emptyObjectLabel.AddToClassList("empty-object-list-label"); - _objectListContainer.Add(_emptyObjectLabel); + + // Nothing loaded yet (or still loading): show the empty/loading overlay, hide the list. if (selectedType != null && _selectedObjects.Count == 0) { + _filteredObjects.Clear(); + _objectListView.RefreshItems(); + _objectListView.style.display = DisplayStyle.None; + _emptyObjectLabel.text = + _isLoadingObjectsAsync && _asyncLoadTargetType == selectedType + ? "Loading objects..." + : $"No objects of type '{selectedType.Name}' found.\nUse the '+' button above to create one."; _emptyObjectLabel.style.display = DisplayStyle.Flex; return; } ApplyLabelFilter(buildObjectsView: false); - _emptyObjectLabel.style.display = DisplayStyle.None; if (_filteredObjects.Count == 0) { - // If _selectedObjects has items, then filter is active and hiding all - if (_selectedObjects.Count > 0) - { - Label noMatchLabel = new( - $"No objects of type '{NamespaceController.GetTypeDisplayName(_namespaceController.SelectedType)}' match the current label filter." - ) - { - style = { alignSelf = Align.Center }, - }; - noMatchLabel.AddToClassList("empty-object-list-label"); - _objectListContainer.Add(noMatchLabel); - } - else - { - Label noMatchLabel = new( - $"No objects of type '{NamespaceController.GetTypeDisplayName(_namespaceController.SelectedType)}' found." - ) - { - style = { alignSelf = Align.Center }, - }; - noMatchLabel.AddToClassList("empty-object-list-label"); - _objectListContainer.Add(noMatchLabel); - } + _objectListView.RefreshItems(); + _objectListView.style.display = DisplayStyle.None; + _emptyObjectLabel.text = + _selectedObjects.Count > 0 + ? $"No objects of type '{NamespaceController.GetTypeDisplayName(selectedType)}' match the current label filter." + : $"No objects of type '{NamespaceController.GetTypeDisplayName(selectedType)}' found."; + _emptyObjectLabel.style.display = DisplayStyle.Flex; return; } - if (_filteredObjects.Count <= MaxObjectsPerPage) - { - if (_objectPageController != null) - { - _objectPageController.style.display = DisplayStyle.None; - } - for (int i = 0; i < _filteredObjects.Count; i++) - { - BuildObjectRow(_filteredObjects[i], i); - } - } - else - { - if (_objectPageController != null) - { - _objectPageController.style.display = DisplayStyle.Flex; - } - - _maxPageField.value = _filteredObjects.Count / MaxObjectsPerPage; - int currentPage = GetCurrentPage(_namespaceController.SelectedType); - currentPage = Mathf.Clamp( - currentPage, - 0, - _filteredObjects.Count / MaxObjectsPerPage - ); - _currentPageField.SetValueWithoutNotify(currentPage); - - _previousPageButton.EnableInClassList("go-button-disabled", currentPage <= 0); - _previousPageButton.EnableInClassList( - StyleConstants.ActionButtonClass, - 0 < currentPage - ); - _previousPageButton.EnableInClassList("go-button", 0 < currentPage); - - _nextPageButton.EnableInClassList( - "go-button-disabled", - _maxPageField.value <= currentPage - ); - _nextPageButton.EnableInClassList( - StyleConstants.ActionButtonClass, - currentPage < _maxPageField.value - ); - _nextPageButton.EnableInClassList("go-button", currentPage < _maxPageField.value); - - int max = Mathf.Min((currentPage + 1) * MaxObjectsPerPage, _filteredObjects.Count); - for (int i = currentPage * MaxObjectsPerPage; i < max; i++) - { - BuildObjectRow(_filteredObjects[i], i); - } - } - } + _emptyObjectLabel.style.display = DisplayStyle.None; + _objectListView.style.display = DisplayStyle.Flex; - private void BuildObjectRow(ScriptableObject dataObject, int index) - { - if (dataObject == null) - { - return; - } + // Drag-reorder is only safe (maps 1:1 to the saved order) when no label filter is hiding + // items, i.e. the filtered list matches the full list. + _objectListView.reorderable = _filteredObjects.Count == _selectedObjects.Count; - string dataObjectName; - if (dataObject is IDisplayable displayable) - { - dataObjectName = displayable.Title; - } - else - { - dataObjectName = dataObject.name; - } + _objectListView.RefreshItems(); - VisualElement objectItemRow = new() - { - name = $"object-item-row-{dataObject.GetObjectIdString()}", - }; - objectItemRow.AddToClassList(ObjectItemClass); - objectItemRow.AddToClassList(StyleConstants.ClickableClass); - objectItemRow.style.flexDirection = FlexDirection.Row; - objectItemRow.style.alignItems = Align.Center; - objectItemRow.userData = dataObject; - objectItemRow.RegisterCallback(OnObjectPointerDown); + // Re-resolve the selection by identity (indices shift as async batches load). + int selectedIndex = + _selectedObject != null ? _filteredObjects.IndexOf(_selectedObject) : -1; + _suppressListSelectionCallback = true; + _objectListView.SetSelectionWithoutNotify( + selectedIndex >= 0 ? new[] { selectedIndex } : Array.Empty() + ); + _suppressListSelectionCallback = false; + } + + // Builds the reusable skeleton of an object row (no per-object data) for the ListView's + // makeItem. Button handlers resolve the current object from the row's userData at click time + // so the element can be safely reused across items (as ListView virtualization requires). + private VisualElement MakeObjectRow() + { + // Outer element: the ListView forces this to fixedItemHeight (FixedHeight virtualization), + // so it stays a transparent slot that vertically centers the visible box. The box can't + // carry its own vertical margin without breaking the ListView's fixed-height scroll math, + // so the inter-row gap comes from centering a shorter box inside the taller slot. + VisualElement row = new() { name = "object-row-slot" }; + row.AddToClassList(StyleConstants.ClickableClass); + row.style.flexDirection = FlexDirection.Column; + row.style.justifyContent = Justify.Center; + // Force the slot to the full fixed-item height so the shorter box centers within it + // (the ListView otherwise shrinks the slot to the box, leaving no room to center/gap). + row.style.height = ObjectRowFixedHeight; + + VisualElement box = new() { name = "object-item-box" }; + box.AddToClassList(ObjectItemClass); + box.AddToClassList(StyleConstants.ClickableClass); + box.style.flexDirection = FlexDirection.Row; + box.style.alignItems = Align.Center; + row.Add(box); Button goUpButton = new(() => { - _filteredObjects.Remove(dataObject); - _filteredObjects.Insert(0, dataObject); - _filteredObjects.Remove(dataObject); - _filteredObjects.Insert(0, dataObject); - UpdateAndSaveObjectOrderList(dataObject.GetType(), _selectedObjects); - BuildObjectsView(); + if (row.userData is ScriptableObject o) + { + MoveObjectToTop(o); + } }) { name = "go-up-button", text = "↑", - tooltip = $"Move {dataObjectName} to top", }; - if (_selectedObjects.Count == 1 || index == 0) - { - goUpButton.AddToClassList("go-button-disabled"); - } - else - { - goUpButton.AddToClassList(StyleConstants.ActionButtonClass); - goUpButton.AddToClassList("go-button"); - } - - objectItemRow.Add(goUpButton); + goUpButton.RegisterCallback(StopRowChildPointerDown); + box.Add(goUpButton); Button goDownButton = new(() => { - _selectedObjects.Remove(dataObject); - _selectedObjects.Add(dataObject); - _filteredObjects.Remove(dataObject); - _filteredObjects.Add(dataObject); - UpdateAndSaveObjectOrderList(dataObject.GetType(), _selectedObjects); - BuildObjectsView(); + if (row.userData is ScriptableObject o) + { + MoveObjectToBottom(o); + } }) { name = "go-down-button", text = "↓", - tooltip = $"Move {dataObjectName} to bottom", }; - if (_filteredObjects.Count == 1 || index == _filteredObjects.Count - 1) - { - goDownButton.AddToClassList("go-button-disabled"); - } - else - { - goDownButton.AddToClassList(StyleConstants.ActionButtonClass); - goDownButton.AddToClassList("go-button"); - } - - objectItemRow.Add(goDownButton); + goDownButton.RegisterCallback(StopRowChildPointerDown); + box.Add(goDownButton); VisualElement contentArea = new() { name = "content" }; contentArea.AddToClassList(ObjectItemContentClass); contentArea.AddToClassList(StyleConstants.ClickableClass); - objectItemRow.Add(contentArea); + box.Add(contentArea); - Label titleLabel = new(dataObjectName) { name = "object-item-label" }; + Label titleLabel = new() { name = "object-item-label" }; titleLabel.AddToClassList("object-item__label"); titleLabel.AddToClassList(StyleConstants.ClickableClass); contentArea.Add(titleLabel); @@ -5969,9 +6260,16 @@ private void BuildObjectRow(ScriptableObject dataObject, int index) }, }; actionsArea.AddToClassList(ObjectItemActionsClass); - objectItemRow.Add(actionsArea); + actionsArea.RegisterCallback(StopRowChildPointerDown); + box.Add(actionsArea); - Button cloneButton = new(() => CloneObject(dataObject)) + Button cloneButton = new(() => + { + if (row.userData is ScriptableObject o) + { + CloneObject(o); + } + }) { text = "++", tooltip = "Clone Object", @@ -5981,7 +6279,13 @@ private void BuildObjectRow(ScriptableObject dataObject, int index) actionsArea.Add(cloneButton); Button renameButton = null; - renameButton = new Button(() => OpenRenamePopover(titleLabel, renameButton, dataObject)) + renameButton = new Button(() => + { + if (row.userData is ScriptableObject o) + { + OpenRenamePopover(titleLabel, renameButton, o); + } + }) { text = "@", tooltip = "Rename Object", @@ -5992,103 +6296,224 @@ private void BuildObjectRow(ScriptableObject dataObject, int index) Button moveButton = new(() => { - if (dataObject == null) + if (row.userData is ScriptableObject o) { - return; + MoveObjectToFolder(o); } + }) + { + text = "➔", + tooltip = "Move Object", + }; + moveButton.AddToClassList(StyleConstants.ActionButtonClass); + moveButton.AddToClassList("move-button"); + actionsArea.Add(moveButton); - string assetPath = AssetDatabase.GetAssetPath(dataObject); - string startDirectory = Path.GetDirectoryName(assetPath) ?? string.Empty; - string selectedAbsolutePath = EditorUtility.OpenFolderPanel( - title: "Select New Location (Must be inside Assets)", - folder: startDirectory, - defaultName: "" - ); - - if (string.IsNullOrWhiteSpace(selectedAbsolutePath)) + Button deleteButton = null; + deleteButton = new Button(() => + { + if (row.userData is ScriptableObject o) { - return; + OpenConfirmDeletePopover(deleteButton, o); } + }) + { + text = "X", + tooltip = "Delete Object", + }; + deleteButton.AddToClassList(StyleConstants.ActionButtonClass); + deleteButton.AddToClassList("delete-button"); + actionsArea.Add(deleteButton); - selectedAbsolutePath = Path.GetFullPath(selectedAbsolutePath).SanitizePath(); + return row; + } - string projectAssetsPath = Path.GetFullPath(Application.dataPath).SanitizePath(); + // Populates a row skeleton for a specific object at a display index. Serves as the ListView + // bindItem. + private void BindObjectRow(VisualElement row, ScriptableObject dataObject, int index) + { + if (row == null || dataObject == null) + { + return; + } - if ( - !selectedAbsolutePath.StartsWith( - projectAssetsPath, - StringComparison.OrdinalIgnoreCase - ) - ) - { - Debug.LogError("Selected folder must be inside the project's Assets folder."); - EditorUtility.DisplayDialog( - "Invalid Folder", - "The selected folder must be inside the project's 'Assets' directory.", - "OK" - ); - return; - } + row.userData = dataObject; + row.name = $"object-item-row-{dataObject.GetObjectIdString()}"; - string relativePath; - if ( - selectedAbsolutePath.Equals( - projectAssetsPath, - StringComparison.OrdinalIgnoreCase - ) - ) - { - relativePath = "Assets"; - } - else - { - relativePath = - "Assets" + selectedAbsolutePath.Substring(projectAssetsPath.Length); - relativePath = relativePath.Replace("//", "/"); - } + string dataObjectName = dataObject is IDisplayable displayable + ? displayable.Title + : dataObject.name; + + Label titleLabel = row.Q