diff --git a/.gitattributes b/.gitattributes index c0e37ba..f2f6500 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,9 @@ ############################################################################### *.sh text eol=lf +# CSharpier follows the repository .editorconfig for C# files. +*.cs text eol=crlf + # Generated Markdown is compared byte-for-byte by the cross-platform harness. .llm/skills/index.md text eol=lf diff --git a/.github/release.md b/.github/release.md new file mode 100644 index 0000000..732a49b --- /dev/null +++ b/.github/release.md @@ -0,0 +1,36 @@ +# Release operations + +Releases are prepared from `main` with the **Prepare release** workflow. Select +`patch`, `minor`, or `major`, or choose `none` and provide an explicit SemVer. +Use `dry_run` first. A real run updates `package.json`, `.llm/context.md`, and +`CHANGELOG.md`, then opens one `release/vX.Y.Z` pull request. + +## Required repository configuration + +- `AUTO_COMMIT_APP_ID`: the numeric ID of a GitHub App installed on this + repository. +- `AUTO_COMMIT_APP_PRIVATE_KEY`: the PEM private key for that App. +- The App installation must have only `Contents: write` and `Pull requests: + write` for this repository. The tag workflow uses only `Contents: write`. +- npm Trusted Publishing must be configured for + `com.wallstop-studios.data-visualizer` with this repository, the + `Publish release artifacts` workflow, and the default branch/tag workflow + identity. No npm token secret is used. + +## Recovery and reruns + +The tag workflow validates the merged release branch and creates an annotated +`vX.Y.Z` tag. An existing tag is never retargeted automatically: a tag at the +same commit is left alone, while a tag at another commit fails closed. + +The publication workflow checks whether the exact npm `name@version` already +exists and skips republishing it. It always validates the checked-out tag and +artifacts first. If npm succeeds but GitHub Release creation fails, rerun the +tag workflow from the existing tag event or use the workflow run again; release +creation updates an existing release and replaces attachments by checksum-safe +name. A failed release-preparation run may be retried only after resolving an +existing branch or PR with the same version. + +All `.unitypackage` assembly and validation uses Python, tar, gzip, and checksum +tools in cloud CI. Unity is not installed, authenticated, or invoked by any +release workflow; EditMode and PlayMode validation remain local gates. diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml new file mode 100644 index 0000000..30ba770 --- /dev/null +++ b/.github/workflows/docs-pages.yml @@ -0,0 +1,70 @@ +name: Documentation Pages + +on: + push: + branches: [main] + paths: + - "docs/**" + - "mkdocs.yml" + - "requirements-docs.txt" + - "scripts/docs/**" + - ".github/workflows/docs-pages.yml" + pull_request: + paths: + - "docs/**" + - "mkdocs.yml" + - "requirements-docs.txt" + - "scripts/docs/**" + - ".github/workflows/docs-pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install documentation dependencies + run: python -m pip install --requirement requirements-docs.txt + + - name: Validate documentation graph and examples + run: python scripts/docs/validate_docs.py + + - name: Build strict site + run: mkdocs build --strict --clean --site-dir site + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + needs: build + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Configure Pages + uses: actions/configure-pages@v5 + - name: Deploy Pages artifact + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 073192a..7433d69 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,100 +1,172 @@ -name: Publish to NPM +name: Publish release artifacts -# Manual, on-demand publishing: 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 -# uses npm Trusted Publishing through GitHub OIDC. +# A tag created by release-tag.yml is the only publication trigger. The tag is +# created with a GitHub App token so this event is not suppressed by GitHub's +# GITHUB_TOKEN recursion protection. on: - workflow_dispatch: - inputs: - dry_run: - description: "Pack and report the version/dist-tag without publishing" - type: boolean - default: false + push: + tags: + - "v*" concurrency: - group: publish-npm + group: publish-${{ github.ref_name }} cancel-in-progress: false permissions: contents: read jobs: - publish: - name: Publish npm package + package: + name: Validate and package release runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - id-token: write # required for npm Trusted Publishing through OIDC + timeout-minutes: 20 steps: - - name: Checkout + - name: Checkout exact tag uses: actions/checkout@v7 with: + ref: ${{ github.ref }} + fetch-depth: 0 persist-credentials: false - name: Set up Node.js uses: actions/setup-node@v7 with: node-version: "24" - registry-url: "https://registry.npmjs.org" package-manager-cache: false - - name: Pack and resolve version / dist-tag - id: pack + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Validate release metadata + id: release run: | 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." + version="${GITHUB_REF_NAME#v}" + python3 scripts/release/release_tools.py validate-release --root . --version "${version}" + tag="v${version}" + if [ "$(git describe --tags --exact-match HEAD 2>/dev/null || true)" != "${tag}" ]; then + echo "::error::Checked-out commit is not the exact release tag ${tag}." 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 - 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: Verify npm trusted publishing toolchain + echo "version=${version}" >> "${GITHUB_OUTPUT}" + + - name: Run non-Unity repository checks + run: | + set -euo pipefail + npm run lint:llm + pwsh -NoProfile -File scripts/tests/run-all.ps1 + + - name: Pack and verify npm payload + id: npm + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + node -e 'const p=require("./package.json"); if (p.version!==process.env.VERSION || p.name!=="com.wallstop-studios.data-visualizer") process.exit(1);' + npm pack --json --pack-destination "${RUNNER_TEMP}" > "${RUNNER_TEMP}/npm-pack.json" + package_file="${RUNNER_TEMP}/$(jq -r '.[0].filename' "${RUNNER_TEMP}/npm-pack.json")" + [ -f "${package_file}" ] + while IFS= read -r member; do + case "${member}" in + package/Editor|package/Editor.meta|package/Editor/*|package/Runtime|package/Runtime.meta|package/Runtime/*|package/Tests|package/Tests.meta|package/Tests/*|package/docs|package/docs.meta|package/docs/*|package/CHANGELOG.md|package/CHANGELOG.md.meta|package/README.md|package/README.md.meta|package/LICENSE|package/LICENSE.meta|package/package.json|package/package.json.meta) ;; + *) echo "::error::Unexpected npm payload member ${member}"; exit 1 ;; + esac + done < <(tar -tzf "${package_file}" | sed 's#/$##') + sha256sum "${package_file}" > "${package_file}.sha256" + echo "file=${package_file}" >> "${GITHUB_OUTPUT}" + + - name: Build and validate Unity package + id: unity + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + output="${RUNNER_TEMP}/com.wallstop-studios.data-visualizer-${VERSION}.unitypackage" + python3 scripts/release/release_tools.py build-unitypackage --root . --output "${output}" + python3 scripts/release/release_tools.py validate-unitypackage --archive "${output}" --checksum "${output}.sha256" --root . + echo "file=${output}" >> "${GITHUB_OUTPUT}" + + - name: Upload validated release artifacts + uses: actions/upload-artifact@v4 + with: + name: release-${{ github.ref_name }} + if-no-files-found: error + path: | + ${{ steps.npm.outputs.file }} + ${{ steps.npm.outputs.file }}.sha256 + ${{ steps.unity.outputs.file }} + ${{ steps.unity.outputs.file }}.sha256 + + publish: + name: Publish npm and GitHub Release + needs: package + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + id-token: write + steps: + - name: Checkout exact tag + uses: actions/checkout@v7 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js for trusted publishing + uses: actions/setup-node@v7 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Download validated artifacts + uses: actions/download-artifact@v4 + with: + name: release-${{ github.ref_name }} + path: "${{ runner.temp }}/release" + + - name: Publish exact npm version + env: + VERSION: ${{ github.ref_name }} run: | set -euo pipefail - node --version - npm --version + version="${VERSION#v}" + package_file="$(find "${RUNNER_TEMP}/release" -maxdepth 1 -name '*.tgz' -print -quit)" + [ -n "${package_file}" ] required_npm="11.5.1" current_npm="$(npm --version)" if [ "$(printf '%s\n' "${required_npm}" "${current_npm}" | sort -V | head -n1)" != "${required_npm}" ]; then echo "::error::npm ${current_npm} is too old; trusted publishing requires npm >= ${required_npm}." exit 1 fi + pkg="$(node -p 'require("./package.json").name')" + if npm view "${pkg}@${version}" version >/dev/null 2>&1; then + echo "${pkg}@${version} already exists; skipping irreversible publish." + else + npm_tag="$(python3 -c 'import sys; sys.path.insert(0, "scripts/release"); from release_tools import npm_dist_tag; print(npm_dist_tag(sys.argv[1]))' "${version}")" + npm publish "${package_file}" --access public --tag "${npm_tag}" + fi - - name: Publish to npm with trusted publishing - if: ${{ inputs.dry_run == false }} + - name: Create or update GitHub Release env: - PKG: ${{ steps.pack.outputs.pkg }} - VER: ${{ steps.pack.outputs.ver }} - NPM_TAG: ${{ steps.pack.outputs.npm_tag }} - PACKAGE_FILE: ${{ steps.pack.outputs.package_file }} + TAG: ${{ github.ref_name }} 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." + version="${TAG#v}" + notes="$(python3 scripts/release/release_tools.py changelog-section --root . --version "${version}" | jq -r .section)" + if python3 -c 'import sys; sys.path.insert(0, "scripts/release"); from release_tools import parse_version; print(bool(parse_version(sys.argv[1])[3]))' "${version}" | grep -qx true; then prerelease_flag=--prerelease; else prerelease_flag=; fi + files=("${RUNNER_TEMP}/release"/*) + if gh release view "${TAG}" >/dev/null 2>&1; then + gh release edit "${TAG}" --title "${TAG}" --notes "${notes}" ${prerelease_flag} + gh release upload "${TAG}" "${files[@]}" --clobber else - npm publish "${PACKAGE_FILE}" --access public --tag "${NPM_TAG}" - echo "::notice::Published ${PKG}@${VER} to dist-tag '${NPM_TAG}'." + gh release create "${TAG}" "${files[@]}" --title "${TAG}" --notes "${notes}" ${prerelease_flag} 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/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 0000000..1a0c25c --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,133 @@ +name: Prepare release + +on: + workflow_dispatch: + inputs: + bump: + description: "Version bump (choose none when providing an explicit version)" + type: choice + required: true + default: none + options: + - none + - patch + - minor + - major + version: + description: "Explicit SemVer (leave empty when using a bump)" + type: string + required: false + release_date: + description: "Release date in YYYY-MM-DD (defaults to today)" + type: string + required: false + dry_run: + description: "Validate and report without modifying or opening a PR" + type: boolean + required: true + default: false + +concurrency: + group: release-prepare + cancel-in-progress: false + +permissions: + contents: read + +jobs: + prepare: + name: Prepare versioned release PR + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout default branch + uses: actions/checkout@v7 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Prepare release files + id: release + env: + BUMP: ${{ inputs.bump }} + EXPLICIT_VERSION: ${{ inputs.version }} + RELEASE_DATE: ${{ inputs.release_date }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + args=(--root .) + if [ "${BUMP}" != "none" ]; then + args+=(--bump "${BUMP}") + elif [ -n "${EXPLICIT_VERSION}" ]; then + args+=(--version "${EXPLICIT_VERSION}") + else + echo "::error::Choose a bump or provide an explicit version." + exit 1 + fi + if [ -n "${RELEASE_DATE}" ]; then + args+=(--date "${RELEASE_DATE}") + fi + if [ "${DRY_RUN}" = "true" ]; then + args+=(--dry-run) + fi + result="$(python3 scripts/release/release_tools.py prepare "${args[@]}")" + echo "${result}" + version="$(printf '%s' "${result}" | jq -r .version)" + current="$(printf '%s' "${result}" | jq -r .current)" + { + echo "version=${version}" + echo "current=${current}" + } >> "${GITHUB_OUTPUT}" + + - name: Validate prepared release tree + if: ${{ inputs.dry_run == false }} + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + python3 scripts/release/release_tools.py validate-release --root . --version "${VERSION}" + npm pack --dry-run >/dev/null + git diff --check + + - name: Create narrowly scoped GitHub App token + if: ${{ inputs.dry_run == false }} + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.AUTO_COMMIT_APP_ID }} + private-key: ${{ secrets.AUTO_COMMIT_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: DataVisualizer + permission-contents: write + permission-pull-requests: write + + - name: Commit and open release PR + if: ${{ inputs.dry_run == false }} + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + VERSION: ${{ steps.release.outputs.version }} + CURRENT: ${{ steps.release.outputs.current }} + run: | + set -euo pipefail + branch="release/v${VERSION}" + if git ls-remote --exit-code --heads origin "${branch}" >/dev/null 2>&1; then + echo "::error::Release branch ${branch} already exists; refusing to overwrite it." + exit 1 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add package.json .llm/context.md CHANGELOG.md + git commit -m "Bump version from ${CURRENT} to ${VERSION}" + remote="https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push "${remote}" "HEAD:${branch}" + export GH_TOKEN="${APP_TOKEN}" + gh pr create --repo "${GITHUB_REPOSITORY}" --head "${branch}" --base main \ + --title "Bump version from ${CURRENT} to ${VERSION}" \ + --body "Automated release preparation for ${VERSION}. The tag workflow will validate and create v${VERSION} after this PR is merged." diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 0000000..3996901 --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,80 @@ +name: Tag merged release + +on: + pull_request: + branches: [main] + types: [closed] + +concurrency: + group: release-tag-${{ github.event.pull_request.head.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + tag: + name: Tag merged release PR + if: ${{ github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v') }} + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout merged default branch + uses: actions/checkout@v7 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Verify release tree and derive tag + id: release + env: + RELEASE_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + case "${RELEASE_BRANCH}" in + release/v*) ;; + *) echo "::error::Unexpected release branch ${RELEASE_BRANCH}"; exit 1 ;; + esac + version="${RELEASE_BRANCH#release/v}" + python3 scripts/release/release_tools.py validate-release --root . --version "${version}" + tag="v${version}" + echo "version=${version}" >> "${GITHUB_OUTPUT}" + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + + - name: Create narrowly scoped GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.AUTO_COMMIT_APP_ID }} + private-key: ${{ secrets.AUTO_COMMIT_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: DataVisualizer + permission-contents: write + + - name: Create immutable annotated tag + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + git fetch --tags --force origin + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + existing="$(git rev-list -n 1 "${TAG}^{commit}")" + if [ "${existing}" != "$(git rev-parse HEAD)" ]; then + echo "::error::${TAG} already exists at ${existing}; refusing to retarget it." + exit 1 + fi + echo "${TAG} already points at the merged release commit; leaving it unchanged." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${TAG}" -m "Release ${TAG}" "$(git rev-parse HEAD)" + remote="https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push "${remote}" "${TAG}" diff --git a/.gitignore b/.gitignore index 3a70952..5a03e76 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,4 @@ opencode.json.meta .artifacts/ GOAL.md* PLAN.md* +/site/ diff --git a/.llm/context.md b/.llm/context.md index 08ad25f..97667ee 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -31,13 +31,12 @@ Editor/ Editor-only assembly (WallstopStudios.DataVisua Styles/ USS stylesheet + style constants UI/ Reusable UI Toolkit controls Unity/ Asset postprocessor - Utilities/ ReadOnly attribute + drawer, monitor utility + Utilities/ ReadOnly attribute + drawer, monitor and path utilities Extensions/ ObjectId, Color, ListView compat, UI extensions Fonts/ Bundled UI font Runtime/ Runtime assembly (WallstopStudios.DataVisualizer.asmdef) DataVisualizer/ BaseDataObject, lifecycle interfaces, attributes Extensions/ Shared extension methods - Helper/ Directory/Path/Reflection helpers Tests/Editor/ EditMode tests (WallstopStudios.DataVisualizer.Tests.Editor.asmdef) docs/ Screenshots and README assets scripts/ PowerShell automation for this .llm harness diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b4bd34c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this package are documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +- Reduced the player-facing runtime assembly by moving editor-only helpers out of it. + +## [0.0.37] - 2026-07-07 + +### Changed + +- Improved asynchronous ScriptableObject loading and settings persistence. +- Added Unity 6 `EntityId` compatibility. + +[Unreleased]: https://github.com/wallstop/DataVisualizer/compare/v0.0.37...HEAD +[0.0.37]: https://github.com/wallstop/DataVisualizer/releases/tag/v0.0.37 diff --git a/CHANGELOG.md.meta b/CHANGELOG.md.meta new file mode 100644 index 0000000..9c1aa4c --- /dev/null +++ b/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d0a3ccfb6d2b4f1aa76f7c2fd4d7dc34 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/DataVisualizer/Automation.meta b/Editor/DataVisualizer/Automation.meta new file mode 100644 index 0000000..e044247 --- /dev/null +++ b/Editor/DataVisualizer/Automation.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3a8d7f1c5e924b06a1f9d2c7e8b45063 diff --git a/Editor/DataVisualizer/Automation/DataVisualizerAutomation.cs b/Editor/DataVisualizer/Automation/DataVisualizerAutomation.cs new file mode 100644 index 0000000..ece8f12 --- /dev/null +++ b/Editor/DataVisualizer/Automation/DataVisualizerAutomation.cs @@ -0,0 +1,590 @@ +namespace WallstopStudios.DataVisualizer.Editor.Automation +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using UnityEditor; + using UnityEngine; + using WallstopStudios.DataVisualizer.Editor; + using WallstopStudios.DataVisualizer.Editor.Data; + + [Serializable] + public sealed class DataVisualizerConfiguration + { + public string dataFolderPath = DataVisualizerSettings.DefaultDataFolderPath; + public bool persistStateInSettingsAsset; + public bool selectActiveObject; + + public DataVisualizerConfiguration Clone() + { + return new DataVisualizerConfiguration + { + dataFolderPath = dataFolderPath, + persistStateInSettingsAsset = persistStateInSettingsAsset, + selectActiveObject = selectActiveObject, + }; + } + + internal bool TryValidate(out string diagnostic) + { + if (string.IsNullOrWhiteSpace(dataFolderPath)) + { + diagnostic = "dataFolderPath is required."; + return false; + } + + string normalizedPath = dataFolderPath.Replace('\\', '/').TrimEnd('/'); + if ( + !normalizedPath.Equals("Assets", StringComparison.Ordinal) + && !normalizedPath.StartsWith("Assets/", StringComparison.Ordinal) + ) + { + diagnostic = "dataFolderPath must be inside the Assets folder."; + return false; + } + + try + { + string assetRoot = Path.GetFullPath(Application.dataPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + string relativePath = + normalizedPath.Length == "Assets".Length + ? string.Empty + : normalizedPath.Substring("Assets/".Length); + string candidate = Path.GetFullPath(Path.Combine(assetRoot, relativePath)); + if ( + !candidate.Equals(assetRoot, StringComparison.OrdinalIgnoreCase) + && !candidate.StartsWith( + assetRoot + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase + ) + && !candidate.StartsWith( + assetRoot + Path.AltDirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase + ) + ) + { + diagnostic = "dataFolderPath must resolve inside the Assets folder."; + return false; + } + + string canonicalRelativePath = candidate + .Substring(assetRoot.Length) + .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + dataFolderPath = string.IsNullOrEmpty(canonicalRelativePath) + ? "Assets" + : "Assets/" + canonicalRelativePath.Replace('\\', '/'); + diagnostic = string.Empty; + return true; + } + catch (ArgumentException exception) + { + diagnostic = $"dataFolderPath is invalid: {exception.Message}"; + return false; + } + catch (IOException exception) + { + diagnostic = $"dataFolderPath is invalid: {exception.Message}"; + return false; + } + catch (NotSupportedException exception) + { + diagnostic = $"dataFolderPath is invalid: {exception.Message}"; + return false; + } + } + } + + [Serializable] + public sealed class DataVisualizerTypeDescriptor + { + public string assemblyQualifiedName; + public string fullName; + public string name; + } + + [Serializable] + public sealed class DataVisualizerAssetMetadata + { + public string guid; + public string path; + public string assemblyQualifiedTypeName; + public string displayName; + } + + [Serializable] + public sealed class DataVisualizerAssetMetadataPage + { + public int page; + public int pageSize; + public int totalCount; + public bool isComplete; + public List items = new(); + } + + [Serializable] + public sealed class DataVisualizerIndexProgress + { + public bool windowAvailable; + public bool isPlayModeSuspended; + public bool isLoadingObjects; + public bool isLoadingSearchCache; + public bool isSearchCachePopulated; + public bool refreshPending; + public int loadedObjectCount; + public int objectLoadTotalCount; + public int pendingObjectCount; + public int pendingSearchCacheCount; + public string selectedTypeAssemblyQualifiedName; + public bool isComplete; + } + + [Serializable] + public sealed class DataVisualizerOperationResult + { + public bool succeeded; + public string diagnostic; + + public static DataVisualizerOperationResult Success() + { + return new DataVisualizerOperationResult + { + succeeded = true, + diagnostic = string.Empty, + }; + } + + public static DataVisualizerOperationResult Failure(string message) + { + return new DataVisualizerOperationResult + { + succeeded = false, + diagnostic = message ?? "The operation failed.", + }; + } + } + + public static partial class DataVisualizerAutomation + { + private const string SettingsPath = "Assets/Editor/DataVisualizerSettings.asset"; + private const string UserStateFileName = "DataVisualizerUserState.json"; + + public static DataVisualizerConfiguration ReadConfiguration() + { + DataVisualizerSettings settings = LoadSettings(createIfMissing: false); + if (settings == null) + { + return new DataVisualizerConfiguration(); + } + return new DataVisualizerConfiguration + { + dataFolderPath = settings.DataFolderPath, + persistStateInSettingsAsset = settings.persistStateInSettingsAsset, + selectActiveObject = settings.selectActiveObject, + }; + } + + public static DataVisualizerOperationResult ApplyConfiguration( + DataVisualizerConfiguration configuration + ) + { + if (EditorApplication.isPlayingOrWillChangePlaymode) + { + return DataVisualizerOperationResult.Failure( + "Configuration cannot be applied while entering or in Play Mode." + ); + } + + if (configuration == null) + { + return DataVisualizerOperationResult.Failure("Configuration is required."); + } + + DataVisualizerConfiguration copy = configuration.Clone(); + if (!copy.TryValidate(out string diagnostic)) + { + return DataVisualizerOperationResult.Failure(diagnostic); + } + + DataVisualizerSettings settings = LoadSettings(); + bool persistenceModeChanged = + settings.persistStateInSettingsAsset != copy.persistStateInSettingsAsset; + if (persistenceModeChanged && DataVisualizer.Instance != null) + { + if ( + !DataVisualizer.Instance.TryApplyPersistenceModeFromAutomation( + copy.persistStateInSettingsAsset, + out diagnostic + ) + ) + { + return DataVisualizerOperationResult.Failure(diagnostic); + } + } + else if ( + persistenceModeChanged + && !TryMigratePersistenceState( + settings, + copy.persistStateInSettingsAsset, + out diagnostic + ) + ) + { + return DataVisualizerOperationResult.Failure(diagnostic); + } + + settings._dataFolderPath = copy.dataFolderPath; + settings.persistStateInSettingsAsset = copy.persistStateInSettingsAsset; + settings.selectActiveObject = copy.selectActiveObject; + settings.MarkDirty(); + AssetDatabase.SaveAssets(); + return DataVisualizerOperationResult.Success(); + } + + public static string ExportConfigurationJson() + { + return JsonUtility.ToJson(ReadConfiguration(), true); + } + + public static DataVisualizerOperationResult ImportConfigurationJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return DataVisualizerOperationResult.Failure("Configuration JSON is required."); + } + + try + { + DataVisualizerConfiguration configuration = + JsonUtility.FromJson(json); + return ApplyConfiguration(configuration); + } + catch (ArgumentException exception) + { + return DataVisualizerOperationResult.Failure( + $"Configuration JSON is malformed: {exception.Message}" + ); + } + } + + public static IReadOnlyList DiscoverManagedTypes() + { + return TypeCache + .GetTypesDerivedFrom() + .Where(IsManagedType) + .Select(type => new DataVisualizerTypeDescriptor + { + assemblyQualifiedName = type.AssemblyQualifiedName, + fullName = type.FullName ?? type.Name, + name = type.Name, + }) + .OrderBy(type => type.assemblyQualifiedName, StringComparer.Ordinal) + .ToArray(); + } + + public static DataVisualizerAssetMetadataPage QueryAssetMetadata( + string assemblyQualifiedTypeName, + int page, + int pageSize, + string searchFolder = null + ) + { + if (page < 0) + { + throw new ArgumentOutOfRangeException(nameof(page)); + } + + if (pageSize is < 1 or > 500) + { + throw new ArgumentOutOfRangeException(nameof(pageSize)); + } + + if (!string.IsNullOrWhiteSpace(assemblyQualifiedTypeName)) + { + Type requestedType = Type.GetType(assemblyQualifiedTypeName, throwOnError: false); + if (requestedType == null || !IsManagedType(requestedType)) + { + throw new ArgumentException( + "assemblyQualifiedTypeName must identify a discovered managed ScriptableObject type.", + nameof(assemblyQualifiedTypeName) + ); + } + } + + string[] searchFolders = null; + if (!string.IsNullOrWhiteSpace(searchFolder)) + { + searchFolder = searchFolder.Replace('\\', '/').TrimEnd('/'); + if (!AssetDatabase.IsValidFolder(searchFolder)) + { + throw new ArgumentException( + "searchFolder must be a valid project folder.", + nameof(searchFolder) + ); + } + + searchFolders = new[] { searchFolder }; + } + + string[] assetGuids = + searchFolders == null + ? AssetDatabase.FindAssets("t:ScriptableObject") + : AssetDatabase.FindAssets("t:ScriptableObject", searchFolders); + DataVisualizerAssetMetadata[] metadata = assetGuids + .Select(guid => new { guid, path = AssetDatabase.GUIDToAssetPath(guid) }) + .Where(asset => !string.IsNullOrWhiteSpace(asset.path)) + .Select(asset => + { + Type type = AssetDatabase.GetMainAssetTypeAtPath(asset.path); + return new DataVisualizerAssetMetadata + { + guid = asset.guid, + path = asset.path, + assemblyQualifiedTypeName = type?.AssemblyQualifiedName, + displayName = Path.GetFileNameWithoutExtension(asset.path), + }; + }) + .Where(asset => + string.IsNullOrWhiteSpace(assemblyQualifiedTypeName) + || string.Equals( + asset.assemblyQualifiedTypeName, + assemblyQualifiedTypeName, + StringComparison.Ordinal + ) + ) + .OrderBy(asset => asset.path, StringComparer.Ordinal) + .ThenBy(asset => asset.guid, StringComparer.Ordinal) + .ToArray(); + + int offset = checked(page * pageSize); + DataVisualizerAssetMetadataPage result = new() + { + page = page, + pageSize = pageSize, + totalCount = metadata.Length, + isComplete = true, + }; + result.items.AddRange(metadata.Skip(offset).Take(pageSize)); + return result; + } + + public static DataVisualizerOperationResult RefreshAssets() + { + if (EditorApplication.isPlayingOrWillChangePlaymode) + { + return DataVisualizerOperationResult.Failure( + "Asset refresh cannot be requested while entering or in Play Mode." + ); + } + + AssetDatabase.Refresh(); + DataVisualizer.SignalRefresh(); + return DataVisualizerOperationResult.Success(); + } + + public static DataVisualizerIndexProgress GetIndexProgress() + { + return DataVisualizer.Instance == null + ? new DataVisualizerIndexProgress { isComplete = false } + : DataVisualizer.Instance.GetIndexProgressForAutomation(); + } + + public static DataVisualizerOperationResult SelectAsset(string guid) + { + ScriptableObject asset = LoadAsset(guid, out string diagnostic); + if (asset == null) + { + return DataVisualizerOperationResult.Failure(diagnostic); + } + + Selection.activeObject = asset; + return DataVisualizerOperationResult.Success(); + } + + public static DataVisualizerOperationResult OpenAsset(string guid) + { + ScriptableObject asset = LoadAsset(guid, out string diagnostic); + if (asset == null) + { + return DataVisualizerOperationResult.Failure(diagnostic); + } + + return AssetDatabase.OpenAsset(asset) + ? DataVisualizerOperationResult.Success() + : DataVisualizerOperationResult.Failure( + "Unity could not open the requested asset." + ); + } + + private static ScriptableObject LoadAsset(string guid, out string diagnostic) + { + if (EditorApplication.isPlayingOrWillChangePlaymode) + { + diagnostic = "Asset selection cannot run while entering or in Play Mode."; + return null; + } + + if (string.IsNullOrWhiteSpace(guid)) + { + diagnostic = "Asset GUID is required."; + return null; + } + + string path = AssetDatabase.GUIDToAssetPath(guid); + ScriptableObject asset = string.IsNullOrWhiteSpace(path) + ? null + : AssetDatabase.LoadMainAssetAtPath(path) as ScriptableObject; + diagnostic = + asset == null ? "The requested ScriptableObject asset was not found." : null; + return asset; + } + + private static bool IsManagedType(Type type) + { + return type != null + && type != typeof(ScriptableObject) + && !type.IsAbstract + && !type.IsGenericType + && !type.IsInterface + && !type.IsNestedPrivate + && !typeof(Editor).IsAssignableFrom(type) + && !typeof(EditorWindow).IsAssignableFrom(type) + && !IsScriptableSingleton(type) + && type.Namespace?.StartsWith("UnityEditor", StringComparison.Ordinal) != true + && type.Namespace?.StartsWith("UnityEngine", StringComparison.Ordinal) != true; + } + + private static bool IsScriptableSingleton(Type type) + { + Type baseType = type?.BaseType; + return baseType?.IsGenericType == true + && baseType.GetGenericTypeDefinition() == typeof(ScriptableSingleton<>); + } + + private static DataVisualizerSettings LoadSettings(bool createIfMissing = true) + { + DataVisualizerSettings settings = AssetDatabase.LoadAssetAtPath( + SettingsPath + ); + if (settings != null) + { + return settings; + } + + DataVisualizerSettings[] foundSettings = AssetDatabase + .FindAssets($"t:{nameof(DataVisualizerSettings)}") + .Select(AssetDatabase.GUIDToAssetPath) + .Select(AssetDatabase.LoadAssetAtPath) + .Where(candidate => candidate != null) + .ToArray(); + if (foundSettings.Length > 0) + { + return foundSettings[0]; + } + + if (!createIfMissing) + { + return null; + } + + string directory = Path.GetDirectoryName(SettingsPath)?.Replace('\\', '/'); + if (!string.IsNullOrWhiteSpace(directory) && !AssetDatabase.IsValidFolder(directory)) + { + AssetDatabase.CreateFolder("Assets", Path.GetFileName(directory)); + } + + settings = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(settings, SettingsPath); + AssetDatabase.SaveAssets(); + return AssetDatabase.LoadAssetAtPath(SettingsPath) ?? settings; + } + + private static bool TryMigratePersistenceState( + DataVisualizerSettings settings, + bool migrateToSettingsAsset, + out string diagnostic + ) + { + try + { + string userStatePath = Path.Combine( + Application.persistentDataPath, + UserStateFileName + ); + if (migrateToSettingsAsset) + { + DataVisualizerUserState userState = LoadUserState(userStatePath); + settings.HydrateFrom(userState); + diagnostic = string.Empty; + return true; + } + + DataVisualizerUserState stateToPersist = new(); + stateToPersist.HydrateFrom(settings); + string temporaryPath = userStatePath + ".tmp"; + File.WriteAllText(temporaryPath, JsonUtility.ToJson(stateToPersist, true)); + try + { + if (File.Exists(userStatePath)) + { + try + { + File.Replace(temporaryPath, userStatePath, null); + } + catch (PlatformNotSupportedException) + { + File.Copy(temporaryPath, userStatePath, true); + File.Delete(temporaryPath); + } + } + else + { + File.Move(temporaryPath, userStatePath); + } + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + diagnostic = string.Empty; + return true; + } + catch (IOException exception) + { + diagnostic = $"Persistence state migration failed: {exception.Message}"; + return false; + } + catch (UnauthorizedAccessException exception) + { + diagnostic = $"Persistence state migration failed: {exception.Message}"; + return false; + } + catch (InvalidDataException exception) + { + diagnostic = $"Persistence state migration failed: {exception.Message}"; + return false; + } + } + + private static DataVisualizerUserState LoadUserState(string path) + { + if (!File.Exists(path)) + { + return new DataVisualizerUserState(); + } + + DataVisualizerUserState state = DataVisualizerUserState.FromJson( + File.ReadAllText(path) + ); + if (state == null) + { + throw new InvalidDataException("The persisted Data Visualizer state is malformed."); + } + return state; + } + } +} diff --git a/Editor/DataVisualizer/Automation/DataVisualizerAutomation.cs.meta b/Editor/DataVisualizer/Automation/DataVisualizerAutomation.cs.meta new file mode 100644 index 0000000..fbfab6e --- /dev/null +++ b/Editor/DataVisualizer/Automation/DataVisualizerAutomation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7f1b4a0d9c7e4c3a8d1e2f6b5a904c17 diff --git a/Editor/DataVisualizer/Automation/DataVisualizerAutomationCreation.cs b/Editor/DataVisualizer/Automation/DataVisualizerAutomationCreation.cs new file mode 100644 index 0000000..bc89e73 --- /dev/null +++ b/Editor/DataVisualizer/Automation/DataVisualizerAutomationCreation.cs @@ -0,0 +1,372 @@ +namespace WallstopStudios.DataVisualizer.Editor.Automation +{ + using System; + using System.IO; + using System.Linq; + using System.Text.RegularExpressions; + using UnityEditor; + using UnityEngine; + using WallstopStudios.DataVisualizer; + + public static partial class DataVisualizerAutomation + { + private static DataVisualizerAssetOperationResult ExecuteAssetCreationOperation( + DataVisualizerAssetOperationRequest request, + bool preview + ) + { + DataVisualizerAssetOperationResult result = new() { preview = preview }; + if (request.operation == DataVisualizerAssetOperationKind.Create) + { + return ExecuteCreateOperation(request, result, preview); + } + + if (request.guids == null || request.guids.Length != 1) + { + return FailOperation( + result, + "Clone operations require exactly one source asset GUID." + ); + } + if (!TryResolvePath(request.guids[0], out string sourcePath, out string diagnostic)) + { + result.items.Add( + new DataVisualizerAssetOperationItemResult + { + guid = request.guids[0], + diagnostic = diagnostic, + } + ); + return CompleteCreationOperation(result); + } + + ScriptableObject source = + AssetDatabase.LoadMainAssetAtPath(sourcePath) as ScriptableObject; + if (source == null) + { + return FailOperation( + result, + "The requested clone source is not a ScriptableObject." + ); + } + string cloneName = string.IsNullOrWhiteSpace(request.value) + ? BuildCloneName(sourcePath) + : request.value; + if (!TryValidateName(cloneName, out diagnostic)) + { + return FailOperation(result, diagnostic); + } + string directory = Path.GetDirectoryName(sourcePath)?.Replace('\\', '/'); + if (string.IsNullOrWhiteSpace(directory)) + { + return FailOperation(result, "Clone source directory is invalid."); + } + string targetPath = $"{directory}/{cloneName}{Path.GetExtension(sourcePath)}"; + if ( + !string.Equals( + AssetDatabase.GenerateUniqueAssetPath(targetPath), + targetPath, + StringComparison.Ordinal + ) + ) + { + return FailOperation(result, "An asset already exists at the requested path."); + } + if (!TryValidateMove(sourcePath, targetPath, out diagnostic)) + { + return FailOperation(result, diagnostic); + } + + DataVisualizerAssetOperationItemResult item = new() + { + guid = request.guids[0], + originalPath = sourcePath, + resultingPath = targetPath, + }; + result.items.Add(item); + if (preview) + { + item.succeeded = true; + return CompleteCreationOperation(result); + } + if (EditorApplication.isPlayingOrWillChangePlaymode) + { + return FailOperation( + result, + "Asset mutations cannot run while entering or in Play Mode." + ); + } + + ScriptableObject clone = null; + try + { + clone = UnityEngine.Object.Instantiate(source); + if (clone == null) + { + item.diagnostic = "Unity could not instantiate the clone."; + return CompleteCreationOperation(result); + } + if (clone is IDuplicable duplicable) + { + duplicable.BeforeClone(source); + } + AssetDatabase.CreateAsset(clone, targetPath); + item.mutationApplied = true; + AssetDatabase.SaveAssets(); + ScriptableObject cloneAsset = + AssetDatabase.LoadMainAssetAtPath(targetPath) as ScriptableObject; + if (cloneAsset == null) + { + item.diagnostic = "Unity did not create the cloned asset."; + return CompleteCreationOperation(result); + } + if (cloneAsset is IDuplicable cloneDataObject) + { + cloneDataObject.AfterClone(source); + } + AssetDatabase.SaveAssets(); + item.resultingGuid = AssetDatabase.AssetPathToGUID(targetPath); + item.succeeded = !string.IsNullOrWhiteSpace(item.resultingGuid); + if (!item.succeeded) + { + item.diagnostic = "Unity did not assign a GUID to the cloned asset."; + } + } + catch (Exception exception) + { + item.diagnostic = item.mutationApplied + ? $"Clone asset was created, but its lifecycle hook failed: {exception.Message}" + : $"Clone operation failed: {exception.Message}"; + } + finally + { + if (clone != null && string.IsNullOrWhiteSpace(AssetDatabase.GetAssetPath(clone))) + { + UnityEngine.Object.DestroyImmediate(clone); + } + } + return CompleteCreationOperation(result); + } + + private static DataVisualizerAssetOperationResult ExecuteCreateOperation( + DataVisualizerAssetOperationRequest request, + DataVisualizerAssetOperationResult result, + bool preview + ) + { + if (string.IsNullOrWhiteSpace(request.assemblyQualifiedTypeName)) + { + return FailOperation( + result, + "assemblyQualifiedTypeName is required for Create operations." + ); + } + if ( + !TryResolveManagedType( + request.assemblyQualifiedTypeName, + out Type type, + out string diagnostic + ) + ) + { + return FailOperation(result, diagnostic); + } + if (!TryValidateName(request.value, out diagnostic)) + { + return FailOperation(result, diagnostic); + } + if (!TryResolveCreateFolder(request, type, out string folder, out diagnostic)) + { + return FailOperation(result, diagnostic); + } + string targetPath = $"{folder}/{request.value}.asset"; + string absolutePath = Path.Combine( + Application.dataPath, + targetPath.Substring("Assets/".Length).Replace('/', Path.DirectorySeparatorChar) + ); + if (File.Exists(absolutePath)) + { + return FailOperation(result, "An asset already exists at the requested path."); + } + string uniquePath = AssetDatabase.GenerateUniqueAssetPath(targetPath); + if (!string.Equals(uniquePath, targetPath, StringComparison.Ordinal)) + { + return FailOperation(result, "An asset already exists at the requested path."); + } + + DataVisualizerAssetOperationItemResult item = new() + { + originalPath = string.Empty, + resultingPath = targetPath, + diagnostic = string.Empty, + }; + result.items.Add(item); + if (preview) + { + item.succeeded = true; + return CompleteCreationOperation(result); + } + if (EditorApplication.isPlayingOrWillChangePlaymode) + { + return FailOperation( + result, + "Asset mutations cannot run while entering or in Play Mode." + ); + } + + ScriptableObject instance = null; + try + { + EnsureAssetFolder(folder); + instance = ScriptableObject.CreateInstance(type); + if (instance is ICreatable creatable) + { + creatable.BeforeCreate(); + } + AssetDatabase.CreateAsset(instance, targetPath); + item.mutationApplied = true; + AssetDatabase.SaveAssets(); + ScriptableObject createdAsset = + AssetDatabase.LoadMainAssetAtPath(targetPath) as ScriptableObject; + if (createdAsset == null) + { + item.diagnostic = "Unity did not create the requested asset."; + return CompleteCreationOperation(result); + } + if (createdAsset is ICreatable created) + { + created.AfterCreate(); + } + AssetDatabase.SaveAssets(); + item.resultingGuid = AssetDatabase.AssetPathToGUID(targetPath); + item.succeeded = !string.IsNullOrWhiteSpace(item.resultingGuid); + if (!item.succeeded) + { + item.diagnostic = "Unity did not assign a GUID to the created asset."; + } + } + catch (Exception exception) + { + item.diagnostic = item.mutationApplied + ? $"Asset was created, but its lifecycle hook failed: {exception.Message}" + : $"Create operation failed: {exception.Message}"; + } + finally + { + if ( + instance != null + && string.IsNullOrWhiteSpace(AssetDatabase.GetAssetPath(instance)) + ) + { + UnityEngine.Object.DestroyImmediate(instance); + } + } + return CompleteCreationOperation(result); + } + + private static bool TryResolveManagedType( + string assemblyQualifiedTypeName, + out Type type, + out string diagnostic + ) + { + type = Type.GetType(assemblyQualifiedTypeName, throwOnError: false); + if (type == null || !IsManagedType(type)) + { + diagnostic = + "assemblyQualifiedTypeName must identify a discovered managed ScriptableObject type."; + return false; + } + diagnostic = string.Empty; + return true; + } + + private static bool TryResolveCreateFolder( + DataVisualizerAssetOperationRequest request, + Type type, + out string folder, + out string diagnostic + ) + { + folder = request.destinationFolder; + if (string.IsNullOrWhiteSpace(folder)) + { + string baseFolder = ReadConfiguration().dataFolderPath; + string typeFolder = (type.FullName ?? type.Name).Replace('.', '/'); + folder = $"{baseFolder.TrimEnd('/')}/{typeFolder}"; + } + DataVisualizerConfiguration configuration = new() { dataFolderPath = folder }; + if (!configuration.TryValidate(out diagnostic)) + { + return false; + } + folder = configuration.dataFolderPath; + return true; + } + + private static void EnsureAssetFolder(string folder) + { + string[] parts = folder.Split('/'); + string current = parts[0]; + for (int index = 1; index < parts.Length; index++) + { + string next = $"{current}/{parts[index]}"; + if (!AssetDatabase.IsValidFolder(next)) + { + if ( + string.IsNullOrWhiteSpace(AssetDatabase.CreateFolder(current, parts[index])) + ) + { + throw new InvalidOperationException( + $"Unity could not create folder '{next}'." + ); + } + } + current = next; + } + } + + private static string BuildCloneName(string sourcePath) + { + const string pattern = @"\(Clone(\s+-?\d+)?\)"; + string originalName = Regex + .Replace(Path.GetFileNameWithoutExtension(sourcePath), pattern, string.Empty) + .TrimEnd(); + int count = 0; + while (true) + { + string candidate = + $"{originalName} (Clone{(count++ == 0 ? string.Empty : $" {count}")})"; + string path = + $"{Path.GetDirectoryName(sourcePath)?.Replace('\\', '/')}/{candidate}{Path.GetExtension(sourcePath)}"; + if ( + string.Equals( + AssetDatabase.GenerateUniqueAssetPath(path), + path, + StringComparison.Ordinal + ) + ) + { + return candidate; + } + } + } + + private static DataVisualizerAssetOperationResult CompleteCreationOperation( + DataVisualizerAssetOperationResult result + ) + { + result.complete = true; + result.succeeded = result.items.Count > 0 && result.items.All(item => item.succeeded); + if (!result.succeeded && string.IsNullOrWhiteSpace(result.diagnostic)) + { + result.diagnostic = + result + .items.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.diagnostic)) + ?.diagnostic + ?? "The asset creation operation failed."; + } + return result; + } + } +} diff --git a/Editor/DataVisualizer/Automation/DataVisualizerAutomationCreation.cs.meta b/Editor/DataVisualizer/Automation/DataVisualizerAutomationCreation.cs.meta new file mode 100644 index 0000000..3b4a935 --- /dev/null +++ b/Editor/DataVisualizer/Automation/DataVisualizerAutomationCreation.cs.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 7c9e2a1f4d6b8e0c3a5f7b9d1e2c4a6f +timeCreated: 1789001000 +licenseType: Pro +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/DataVisualizer/Automation/DataVisualizerAutomationJson.cs b/Editor/DataVisualizer/Automation/DataVisualizerAutomationJson.cs new file mode 100644 index 0000000..e2a42f0 --- /dev/null +++ b/Editor/DataVisualizer/Automation/DataVisualizerAutomationJson.cs @@ -0,0 +1,755 @@ +namespace WallstopStudios.DataVisualizer.Editor.Automation +{ + using System; + using System.Collections.Generic; + using System.IO; + using UnityEditor; + using UnityEngine; + + public enum DataVisualizerAutomationRequestKind + { + ReadConfiguration = 0, + ApplyConfiguration = 1, + ImportConfiguration = 2, + ExportConfiguration = 3, + DiscoverManagedTypes = 4, + QueryAssetMetadata = 5, + RefreshAssets = 6, + SelectAsset = 7, + OpenAsset = 8, + PreviewAssetOperation = 9, + ApplyAssetOperation = 10, + } + + [Serializable] + public sealed class DataVisualizerAutomationRequest + { + public int schemaVersion = 1; + public string requestId = Guid.NewGuid().ToString("N"); + public DataVisualizerAutomationRequestKind operation; + public DataVisualizerConfiguration configuration; + public string configurationJson; + public string assemblyQualifiedTypeName; + public int page; + public int pageSize = 100; + public string searchFolder; + public string guid; + public DataVisualizerAssetOperationRequest assetOperation; + } + + [Serializable] + public sealed class DataVisualizerAutomationResult + { + public int schemaVersion = 1; + public string requestId; + public DataVisualizerAutomationRequestKind operation; + public bool succeeded; + public bool complete; + public string diagnostic; + public DataVisualizerConfiguration configuration; + public string configurationJson; + public List managedTypes = new(); + public DataVisualizerAssetMetadataPage metadata; + public DataVisualizerIndexProgress indexProgress; + public DataVisualizerAssetOperationResult assetOperation; + } + + public static partial class DataVisualizerAutomation + { + public static string ExecuteRequestJson(string json) + { + return JsonUtility.ToJson(DispatchRequestJson(json), true); + } + + public static DataVisualizerAutomationResult DispatchRequestJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return FailureResult("Automation request JSON is required."); + } + + try + { + if (!TryValidateRequestEnvelope(json, out string envelopeDiagnostic)) + { + return FailureResult( + "Automation request JSON is malformed: " + envelopeDiagnostic + ); + } + + DataVisualizerAutomationRequest request = + JsonUtility.FromJson(json); + return DispatchRequest(request); + } + catch (ArgumentException exception) + { + return FailureResult($"Automation request JSON is malformed: {exception.Message}"); + } + } + + public static DataVisualizerAutomationResult DispatchRequest( + DataVisualizerAutomationRequest request + ) + { + if (request == null) + { + return FailureResult("An automation request is required."); + } + + DataVisualizerAutomationResult result = new() + { + requestId = request.requestId, + operation = request.operation, + }; + if (request.schemaVersion != 1) + { + return FailureResult( + request.requestId, + request.operation, + $"Unsupported automation schema version: {request.schemaVersion}." + ); + } + + if (string.IsNullOrWhiteSpace(request.requestId)) + { + return FailureResult( + request.requestId, + request.operation, + "Automation requestId is required." + ); + } + + if (!Enum.IsDefined(typeof(DataVisualizerAutomationRequestKind), request.operation)) + { + return FailureResult( + request.requestId, + request.operation, + $"The requested automation operation is unsupported: {(int)request.operation}." + ); + } + + try + { + switch (request.operation) + { + case DataVisualizerAutomationRequestKind.ReadConfiguration: + result.configuration = ReadConfiguration(); + return SucceedResult(result); + + case DataVisualizerAutomationRequestKind.ApplyConfiguration: + return CopyOperationResult( + result, + ApplyConfiguration(request.configuration) + ); + + case DataVisualizerAutomationRequestKind.ImportConfiguration: + return CopyOperationResult( + result, + ImportConfigurationJson(request.configurationJson) + ); + + case DataVisualizerAutomationRequestKind.ExportConfiguration: + result.configurationJson = ExportConfigurationJson(); + return SucceedResult(result); + + case DataVisualizerAutomationRequestKind.DiscoverManagedTypes: + result.managedTypes.AddRange(DiscoverManagedTypes()); + return SucceedResult(result); + + case DataVisualizerAutomationRequestKind.QueryAssetMetadata: + result.metadata = QueryAssetMetadata( + request.assemblyQualifiedTypeName, + request.page, + request.pageSize, + request.searchFolder + ); + return SucceedResult(result); + + case DataVisualizerAutomationRequestKind.RefreshAssets: + DataVisualizerOperationResult refreshResult = RefreshAssets(); + result.indexProgress = GetIndexProgress(); + return CopyOperationResult(result, refreshResult); + + case DataVisualizerAutomationRequestKind.SelectAsset: + return CopyOperationResult(result, SelectAsset(request.guid)); + + case DataVisualizerAutomationRequestKind.OpenAsset: + return CopyOperationResult(result, OpenAsset(request.guid)); + + case DataVisualizerAutomationRequestKind.PreviewAssetOperation: + result.assetOperation = PreviewAssetOperation(request.assetOperation); + return CopyAssetOperationResult(result); + + case DataVisualizerAutomationRequestKind.ApplyAssetOperation: + result.assetOperation = ApplyAssetOperation(request.assetOperation); + return CopyAssetOperationResult(result); + + default: + return FailureResult( + request.requestId, + request.operation, + "The requested automation operation is unsupported." + ); + } + } + catch (ArgumentException exception) + { + return FailureResult(result.requestId, result.operation, exception.Message); + } + catch (InvalidOperationException exception) + { + return FailureResult(result.requestId, result.operation, exception.Message); + } + catch (Exception exception) + { + return FailureResult( + result.requestId, + result.operation, + $"Automation operation failed: {exception.Message}" + ); + } + } + + public static void ExecuteRequestFile() + { + string requestPath = GetCommandLineArgument("-dataVisualizerRequest"); + string resultPath = GetCommandLineArgument("-dataVisualizerResult"); + if (string.IsNullOrWhiteSpace(resultPath)) + { + EditorApplication.Exit(2); + return; + } + + if (!TryValidateFilePaths(requestPath, resultPath, out string pathDiagnostic)) + { + Debug.LogError(pathDiagnostic); + EditorApplication.Exit(2); + return; + } + + if (string.IsNullOrWhiteSpace(requestPath)) + { + DataVisualizerAutomationResult result = FailureResult( + "-dataVisualizerRequest is required." + ); + TryWriteResultFile(resultPath, result); + EditorApplication.Exit(2); + return; + } + + try + { + DataVisualizerAutomationResult result = DispatchRequestJson( + File.ReadAllText(requestPath) + ); + if (!TryWriteResultFile(resultPath, result, out string writeDiagnostic)) + { + Debug.LogError(writeDiagnostic); + EditorApplication.Exit(2); + return; + } + EditorApplication.Exit(result.succeeded ? 0 : 1); + } + catch (Exception exception) + { + DataVisualizerAutomationResult result = FailureResult( + $"Automation file execution failed: {exception.Message}" + ); + if (!TryWriteResultFile(resultPath, result, out string writeDiagnostic)) + { + Debug.LogError(writeDiagnostic); + EditorApplication.Exit(2); + return; + } + EditorApplication.Exit(1); + } + } + + private static bool TryValidateFilePaths( + string requestPath, + string resultPath, + out string diagnostic + ) + { + diagnostic = string.Empty; + if (!Path.IsPathRooted(requestPath) || !Path.IsPathRooted(resultPath)) + { + diagnostic = "Automation request and result paths must be absolute."; + return false; + } + + try + { + requestPath = Path.GetFullPath(requestPath); + resultPath = Path.GetFullPath(resultPath); + if (string.Equals(requestPath, resultPath, StringComparison.OrdinalIgnoreCase)) + { + diagnostic = "Automation request and result paths must be different."; + return false; + } + + string assetsRoot = Path.GetFullPath(Application.dataPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (IsWithinPath(requestPath, assetsRoot) || IsWithinPath(resultPath, assetsRoot)) + { + diagnostic = + "Automation request and result files cannot be inside the Assets folder."; + return false; + } + } + catch (Exception exception) + when (exception is ArgumentException or IOException or NotSupportedException) + { + diagnostic = $"Automation request/result paths are invalid: {exception.Message}"; + return false; + } + return true; + } + + private static bool IsWithinPath(string candidate, string directory) + { + return candidate.Equals(directory, StringComparison.OrdinalIgnoreCase) + || candidate.StartsWith( + directory + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase + ) + || candidate.StartsWith( + directory + Path.AltDirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase + ); + } + + private static bool TryWriteResultFile(string path, DataVisualizerAutomationResult result) + { + return TryWriteResultFile(path, result, out _); + } + + private static bool TryWriteResultFile( + string path, + DataVisualizerAutomationResult result, + out string diagnostic + ) + { + string temporaryPath = path + ".tmp"; + try + { + string parent = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(parent)) + { + Directory.CreateDirectory(parent); + } + File.WriteAllText(temporaryPath, JsonUtility.ToJson(result, true)); + if (File.Exists(path)) + { + try + { + File.Replace(temporaryPath, path, null); + } + catch (PlatformNotSupportedException) + { + File.Copy(temporaryPath, path, true); + File.Delete(temporaryPath); + } + } + else + { + File.Move(temporaryPath, path); + } + diagnostic = string.Empty; + return true; + } + catch (Exception exception) + { + diagnostic = $"Automation result could not be written: {exception.Message}"; + return false; + } + finally + { + try + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + catch + { + // Preserve the primary result-write diagnostic. + } + } + } + + private static string GetCommandLineArgument(string name) + { + string[] arguments = Environment.GetCommandLineArgs(); + for (int index = 0; index < arguments.Length - 1; index++) + { + if (string.Equals(arguments[index], name, StringComparison.Ordinal)) + { + return arguments[index + 1]; + } + } + + return null; + } + + private static bool TryValidateRequestEnvelope(string json, out string diagnostic) + { + diagnostic = string.Empty; + int index = 0; + HashSet properties = new(StringComparer.Ordinal); + if (!TryConsume(json, ref index, '{')) + { + diagnostic = "Automation request JSON must be a top-level object."; + return false; + } + + bool afterComma = false; + while (true) + { + SkipWhitespace(json, ref index); + if (TryConsume(json, ref index, '}')) + { + if (afterComma) + { + diagnostic = "Automation request JSON cannot contain a trailing comma."; + return false; + } + break; + } + afterComma = false; + + if (!TryReadPropertyName(json, ref index, out string propertyName)) + { + diagnostic = "Automation request JSON contains an invalid property name."; + return false; + } + if (!properties.Add(propertyName)) + { + diagnostic = $"Automation request JSON repeats property '{propertyName}'."; + return false; + } + if (!IsKnownProperty(propertyName)) + { + diagnostic = + $"Automation request JSON contains unknown property '{propertyName}'."; + return false; + } + if (!TryConsume(json, ref index, ':')) + { + diagnostic = + $"Automation request JSON property '{propertyName}' is missing a value."; + return false; + } + JsonValueKind valueKind = ReadValueKind(json, ref index); + if (valueKind == JsonValueKind.Invalid) + { + diagnostic = + $"Automation request JSON property '{propertyName}' has an invalid value."; + return false; + } + if ( + (propertyName == "schemaVersion" || propertyName == "operation") + && valueKind != JsonValueKind.Integer + ) + { + diagnostic = + $"Automation request property '{propertyName}' must be an integer."; + return false; + } + if (propertyName == "requestId" && valueKind != JsonValueKind.String) + { + diagnostic = "Automation request property 'requestId' must be a string."; + return false; + } + + SkipWhitespace(json, ref index); + if (TryConsume(json, ref index, '}')) + { + break; + } + if (!TryConsume(json, ref index, ',')) + { + diagnostic = "Automation request JSON must separate properties with commas."; + return false; + } + afterComma = true; + } + + string[] required = { "schemaVersion", "requestId", "operation" }; + foreach (string propertyName in required) + { + if (!properties.Contains(propertyName)) + { + diagnostic = + "Automation request JSON must contain schemaVersion, requestId, and operation."; + return false; + } + } + SkipWhitespace(json, ref index); + if (index != json.Length) + { + diagnostic = "Automation request JSON contains trailing content."; + return false; + } + return true; + } + + private enum JsonValueKind + { + Invalid, + String, + Integer, + Number, + Object, + Array, + Literal, + } + + private static readonly HashSet KnownProperties = new(StringComparer.Ordinal) + { + "schemaVersion", + "requestId", + "operation", + "configuration", + "configurationJson", + "assemblyQualifiedTypeName", + "page", + "pageSize", + "searchFolder", + "guid", + "assetOperation", + }; + + private static bool IsKnownProperty(string propertyName) + { + return KnownProperties.Contains(propertyName); + } + + private static JsonValueKind ReadValueKind(string json, ref int index) + { + SkipWhitespace(json, ref index); + if (index >= json.Length) + { + return JsonValueKind.Invalid; + } + char valueStart = json[index]; + if (valueStart == '"') + { + return TryReadString(json, ref index) + ? JsonValueKind.String + : JsonValueKind.Invalid; + } + if (valueStart == '{') + { + return TrySkipComposite(json, ref index, '{', '}') + ? JsonValueKind.Object + : JsonValueKind.Invalid; + } + if (valueStart == '[') + { + return TrySkipComposite(json, ref index, '[', ']') + ? JsonValueKind.Array + : JsonValueKind.Invalid; + } + int start = index; + while (index < json.Length && !",}] \t\r\n".Contains(json[index])) + { + index++; + } + string literal = json.Substring(start, index - start); + if (literal == "true" || literal == "false" || literal == "null") + { + return JsonValueKind.Literal; + } + if ( + long.TryParse( + literal, + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out _ + ) + ) + { + return JsonValueKind.Integer; + } + return double.TryParse( + literal, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out _ + ) + ? JsonValueKind.Number + : JsonValueKind.Invalid; + } + + private static bool TryReadPropertyName(string json, ref int index, out string propertyName) + { + propertyName = null; + int start = index; + if (!TryReadString(json, ref index)) + { + return false; + } + string encoded = json.Substring(start, index - start); + try + { + propertyName = JsonUtility + .FromJson("{\"value\":" + encoded + "}") + ?.value; + } + catch (ArgumentException) + { + return false; + } + return !string.IsNullOrEmpty(propertyName); + } + + [Serializable] + private sealed class StringValue + { + public string value; + } + + private static bool TryReadString(string json, ref int index) + { + if (index >= json.Length || json[index++] != '"') + { + return false; + } + bool escaped = false; + while (index < json.Length) + { + char character = json[index++]; + if (escaped) + { + escaped = false; + continue; + } + if (character == '\\') + { + escaped = true; + } + else if (character == '"') + { + return true; + } + else if (character < ' ') + { + return false; + } + } + return false; + } + + private static bool TrySkipComposite(string json, ref int index, char opening, char closing) + { + if (index >= json.Length || json[index++] != opening) + { + return false; + } + int depth = 1; + while (index < json.Length && depth > 0) + { + if (json[index] == '"' && !TryReadString(json, ref index)) + { + return false; + } + if (index >= json.Length) + { + return false; + } + if (json[index] == opening) + { + depth++; + index++; + } + else if (json[index] == closing) + { + depth--; + index++; + } + else + { + index++; + } + } + return depth == 0; + } + + private static void SkipWhitespace(string json, ref int index) + { + while (index < json.Length && char.IsWhiteSpace(json[index])) + { + index++; + } + } + + private static bool TryConsume(string json, ref int index, char expected) + { + SkipWhitespace(json, ref index); + if (index >= json.Length || json[index] != expected) + { + return false; + } + index++; + return true; + } + + private static DataVisualizerAutomationResult CopyOperationResult( + DataVisualizerAutomationResult result, + DataVisualizerOperationResult operationResult + ) + { + result.succeeded = operationResult.succeeded; + result.diagnostic = operationResult.diagnostic; + result.complete = true; + return result; + } + + private static DataVisualizerAutomationResult CopyAssetOperationResult( + DataVisualizerAutomationResult result + ) + { + result.succeeded = result.assetOperation.succeeded; + result.diagnostic = result.assetOperation.diagnostic; + result.complete = result.assetOperation.complete; + return result; + } + + private static DataVisualizerAutomationResult SucceedResult( + DataVisualizerAutomationResult result + ) + { + result.succeeded = true; + result.complete = true; + result.diagnostic = string.Empty; + return result; + } + + private static DataVisualizerAutomationResult FailureResult(string diagnostic) + { + return FailureResult( + null, + DataVisualizerAutomationRequestKind.ReadConfiguration, + diagnostic + ); + } + + private static DataVisualizerAutomationResult FailureResult( + string requestId, + DataVisualizerAutomationRequestKind operation, + string diagnostic + ) + { + return new DataVisualizerAutomationResult + { + requestId = requestId, + operation = operation, + succeeded = false, + complete = true, + diagnostic = diagnostic, + }; + } + } +} diff --git a/Editor/DataVisualizer/Automation/DataVisualizerAutomationJson.cs.meta b/Editor/DataVisualizer/Automation/DataVisualizerAutomationJson.cs.meta new file mode 100644 index 0000000..cf2a83c --- /dev/null +++ b/Editor/DataVisualizer/Automation/DataVisualizerAutomationJson.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7f1c4a9e2b6d4038a5e7c1f9042863bd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/DataVisualizer/Automation/DataVisualizerAutomationOperations.cs b/Editor/DataVisualizer/Automation/DataVisualizerAutomationOperations.cs new file mode 100644 index 0000000..e94998c --- /dev/null +++ b/Editor/DataVisualizer/Automation/DataVisualizerAutomationOperations.cs @@ -0,0 +1,617 @@ +namespace WallstopStudios.DataVisualizer.Editor.Automation +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using UnityEditor; + using UnityEngine; + using WallstopStudios.DataVisualizer; + + public enum DataVisualizerAssetOperationKind + { + Rename = 0, + Move = 1, + SetLabels = 2, + Delete = 3, + Create = 4, + Clone = 5, + SetSerializedProperty = 6, + } + + public enum DataVisualizerSerializedValueKind + { + String = 0, + Boolean = 1, + Integer = 2, + Long = 3, + Float = 4, + Double = 5, + Enum = 6, + ObjectReferenceGuid = 7, + Color = 8, + Vector2 = 9, + Vector3 = 10, + Vector4 = 11, + Quaternion = 12, + Rect = 13, + Bounds = 14, + Vector2Int = 15, + Vector3Int = 16, + RectInt = 17, + BoundsInt = 18, + ArraySize = 19, + } + + [Serializable] + public sealed class DataVisualizerSerializedValue + { + public DataVisualizerSerializedValueKind kind; + public string stringValue = string.Empty; + public bool boolValue; + public int intValue; + public long longValue; + public float floatValue; + public double doubleValue; + public string objectReferenceGuid = string.Empty; + public UnityEngine.Color colorValue; + public UnityEngine.Vector2 vector2Value; + public UnityEngine.Vector3 vector3Value; + public UnityEngine.Vector4 vector4Value; + public UnityEngine.Quaternion quaternionValue; + public UnityEngine.Rect rectValue; + public UnityEngine.Bounds boundsValue; + public UnityEngine.Vector2Int vector2IntValue; + public UnityEngine.Vector3Int vector3IntValue; + public UnityEngine.RectInt rectIntValue; + public UnityEngine.BoundsInt boundsIntValue; + } + + [Serializable] + public sealed class DataVisualizerAssetOperationRequest + { + public DataVisualizerAssetOperationKind operation; + public string[] guids = Array.Empty(); + public string value = string.Empty; + public string[] labels = Array.Empty(); + public string assemblyQualifiedTypeName; + public string destinationFolder; + public string propertyPath; + public DataVisualizerSerializedValue serializedValue; + } + + [Serializable] + public sealed class DataVisualizerAssetOperationItemResult + { + public string guid; + public string originalPath; + public string resultingPath; + public string resultingGuid; + public bool succeeded; + public bool mutationApplied; + public string diagnostic; + } + + [Serializable] + public sealed class DataVisualizerAssetOperationResult + { + public bool preview; + public bool succeeded; + public bool complete; + public string diagnostic; + public List items = new(); + } + + public static partial class DataVisualizerAutomation + { + private sealed class PreparedAssetOperation + { + public string guid; + public string path; + public DataVisualizerAssetOperationItemResult item; + } + + public static DataVisualizerAssetOperationResult PreviewAssetOperation( + DataVisualizerAssetOperationRequest request + ) + { + return ExecuteAssetOperation(request, preview: true); + } + + public static DataVisualizerAssetOperationResult ApplyAssetOperation( + DataVisualizerAssetOperationRequest request + ) + { + return ExecuteAssetOperation(request, preview: false); + } + + private static DataVisualizerAssetOperationResult ExecuteAssetOperation( + DataVisualizerAssetOperationRequest request, + bool preview + ) + { + DataVisualizerAssetOperationResult result = new() { preview = preview }; + if (request == null) + { + return FailOperation(result, "An asset operation request is required."); + } + + if ( + request.operation != DataVisualizerAssetOperationKind.Create + && (request.guids == null || request.guids.Length == 0) + ) + { + return FailOperation(result, "At least one asset GUID is required."); + } + + if (!Enum.IsDefined(typeof(DataVisualizerAssetOperationKind), request.operation)) + { + return FailOperation(result, "The requested asset operation is unsupported."); + } + + if ( + request.operation == DataVisualizerAssetOperationKind.Create + || request.operation == DataVisualizerAssetOperationKind.Clone + ) + { + return ExecuteAssetCreationOperation(request, preview); + } + + if ( + request.operation == DataVisualizerAssetOperationKind.SetLabels + && request.labels == null + ) + { + return FailOperation(result, "labels is required for SetLabels operations."); + } + + if (!preview && EditorApplication.isPlayingOrWillChangePlaymode) + { + return FailOperation( + result, + "Asset mutations cannot run while entering or in Play Mode." + ); + } + + bool mutationSucceeded = false; + bool preflightFailed = false; + HashSet sourcePaths = new(StringComparer.OrdinalIgnoreCase); + HashSet seenGuids = new(StringComparer.OrdinalIgnoreCase); + HashSet plannedDestinations = new(StringComparer.OrdinalIgnoreCase); + List prepared = new(); + foreach (string guid in request.guids) + { + DataVisualizerAssetOperationItemResult item = new() { guid = guid }; + result.items.Add(item); + if (!seenGuids.Add(guid ?? string.Empty)) + { + item.diagnostic = "The same asset GUID appears more than once in this request."; + preflightFailed = true; + continue; + } + + try + { + if (!TryResolvePath(guid, out string path, out string diagnostic)) + { + item.diagnostic = diagnostic; + preflightFailed = true; + continue; + } + + item.originalPath = path; + sourcePaths.Add(path); + if (!TryPrepareOperation(request, path, item, out diagnostic)) + { + item.diagnostic = diagnostic; + preflightFailed = true; + continue; + } + + if ( + !TryReserveDestination( + request.operation, + item.resultingPath, + plannedDestinations, + out diagnostic + ) + ) + { + item.diagnostic = diagnostic; + preflightFailed = true; + continue; + } + + prepared.Add( + new PreparedAssetOperation + { + guid = guid, + path = path, + item = item, + } + ); + } + catch (Exception exception) + { + item.diagnostic = $"Asset operation validation failed: {exception.Message}"; + preflightFailed = true; + } + } + + foreach (PreparedAssetOperation operation in prepared) + { + if ( + !string.IsNullOrWhiteSpace(operation.item.resultingPath) + && sourcePaths.Contains(operation.item.resultingPath) + && !string.Equals( + operation.path, + operation.item.resultingPath, + StringComparison.OrdinalIgnoreCase + ) + ) + { + operation.item.diagnostic = + "Batch destination conflicts with another source path; no items were changed."; + preflightFailed = true; + } + } + + if (preflightFailed) + { + return CompleteOperation(result); + } + + foreach (PreparedAssetOperation operation in prepared) + { + if (preview) + { + operation.item.succeeded = true; + continue; + } + + try + { + if ( + ApplyOperation( + request, + operation.guid, + operation.path, + operation.item, + out string diagnostic + ) + ) + { + operation.item.succeeded = true; + mutationSucceeded = true; + } + else + { + operation.item.diagnostic = diagnostic; + } + } + catch (Exception exception) + { + operation.item.diagnostic = $"Asset operation failed: {exception.Message}"; + mutationSucceeded |= + request.operation == DataVisualizerAssetOperationKind.Rename + && string.Equals( + AssetDatabase.AssetPathToGUID(operation.item.resultingPath), + operation.guid, + StringComparison.OrdinalIgnoreCase + ); + } + } + + if (!preview && mutationSucceeded) + { + try + { + AssetDatabase.SaveAssets(); + if (request.operation == DataVisualizerAssetOperationKind.Delete) + { + AssetDatabase.Refresh(); + } + } + catch (Exception exception) + { + result.diagnostic = + $"Asset operation finalization failed after applying item changes: {exception.Message}"; + } + } + + return CompleteOperation(result); + } + + private static DataVisualizerAssetOperationResult CompleteOperation( + DataVisualizerAssetOperationResult result + ) + { + result.complete = true; + result.succeeded = + string.IsNullOrWhiteSpace(result.diagnostic) + && result.items.Count > 0 + && result.items.All(item => item.succeeded); + if (!result.succeeded && string.IsNullOrWhiteSpace(result.diagnostic)) + { + result.diagnostic = "One or more asset operations failed."; + } + return result; + } + + private static bool TryResolvePath(string guid, out string path, out string diagnostic) + { + path = string.IsNullOrWhiteSpace(guid) ? null : AssetDatabase.GUIDToAssetPath(guid); + if (string.IsNullOrWhiteSpace(path)) + { + diagnostic = "The requested asset GUID was not found."; + return false; + } + + if (!path.StartsWith("Assets/", StringComparison.Ordinal)) + { + diagnostic = "Asset operations are limited to assets inside the Assets folder."; + return false; + } + + Type type = AssetDatabase.GetMainAssetTypeAtPath(path); + if (type == null || !typeof(ScriptableObject).IsAssignableFrom(type)) + { + diagnostic = "The requested asset is not a ScriptableObject main asset."; + return false; + } + + diagnostic = string.Empty; + return true; + } + + private static bool TryPrepareOperation( + DataVisualizerAssetOperationRequest request, + string path, + DataVisualizerAssetOperationItemResult item, + out string diagnostic + ) + { + diagnostic = string.Empty; + switch (request.operation) + { + case DataVisualizerAssetOperationKind.Rename: + if (!TryValidateName(request.value, out diagnostic)) + { + return false; + } + + string directory = Path.GetDirectoryName(path)?.Replace('\\', '/'); + string targetPath = string.IsNullOrWhiteSpace(directory) + ? null + : $"{directory}/{request.value}{Path.GetExtension(path)}"; + if (!TryValidateMove(path, targetPath, out diagnostic)) + { + return false; + } + + item.resultingPath = targetPath; + return true; + + case DataVisualizerAssetOperationKind.Move: + if (!TryValidateFolder(request.value, out string targetFolder, out diagnostic)) + { + return false; + } + + targetPath = $"{targetFolder}/{Path.GetFileName(path)}"; + if (!TryValidateMove(path, targetPath, out diagnostic)) + { + return false; + } + + item.resultingPath = targetPath; + return true; + + case DataVisualizerAssetOperationKind.SetLabels: + item.resultingPath = path; + return true; + + case DataVisualizerAssetOperationKind.Delete: + item.resultingPath = string.Empty; + return true; + + case DataVisualizerAssetOperationKind.SetSerializedProperty: + if ( + !TryValidateSerializedProperty( + path, + request.propertyPath, + request.serializedValue, + out diagnostic + ) + ) + { + return false; + } + + item.resultingPath = path; + return true; + + default: + diagnostic = "The requested asset operation is unsupported."; + return false; + } + } + + private static bool ApplyOperation( + DataVisualizerAssetOperationRequest request, + string guid, + string path, + DataVisualizerAssetOperationItemResult item, + out string diagnostic + ) + { + diagnostic = string.Empty; + switch (request.operation) + { + case DataVisualizerAssetOperationKind.Rename: + ScriptableObject original = LoadAsset(guid, out diagnostic); + if (original == null) + { + return false; + } + + if (original is IRenamable renamable) + { + renamable.BeforeRename(request.value); + } + + diagnostic = AssetDatabase.RenameAsset(path, request.value); + if (!string.IsNullOrWhiteSpace(diagnostic)) + { + return false; + } + + if (original is IRenamable renamed) + { + renamed.AfterRename(request.value); + } + return true; + + case DataVisualizerAssetOperationKind.Move: + diagnostic = AssetDatabase.MoveAsset(path, item.resultingPath); + return string.IsNullOrWhiteSpace(diagnostic); + + case DataVisualizerAssetOperationKind.SetLabels: + ScriptableObject asset = LoadAsset(guid, out diagnostic); + if (asset == null) + { + return false; + } + + AssetDatabase.SetLabels( + asset, + (request.labels ?? Array.Empty()) + .Where(label => !string.IsNullOrWhiteSpace(label)) + .Distinct(StringComparer.Ordinal) + .OrderBy(label => label, StringComparer.Ordinal) + .ToArray() + ); + return true; + + case DataVisualizerAssetOperationKind.Delete: + if (!AssetDatabase.DeleteAsset(path)) + { + diagnostic = "Unity could not delete the requested asset."; + return false; + } + return true; + + case DataVisualizerAssetOperationKind.SetSerializedProperty: + return ApplySerializedProperty( + path, + request.propertyPath, + request.serializedValue, + out diagnostic + ); + + default: + diagnostic = "The requested asset operation is unsupported."; + return false; + } + } + + private static bool TryReserveDestination( + DataVisualizerAssetOperationKind operation, + string resultingPath, + HashSet plannedDestinations, + out string diagnostic + ) + { + if ( + operation != DataVisualizerAssetOperationKind.Rename + && operation != DataVisualizerAssetOperationKind.Move + ) + { + diagnostic = string.Empty; + return true; + } + + if (plannedDestinations.Add(resultingPath)) + { + diagnostic = string.Empty; + return true; + } + + diagnostic = "Another item in this request uses the same destination path."; + return false; + } + + private static bool TryValidateName(string value, out string diagnostic) + { + if ( + string.IsNullOrWhiteSpace(value) + || value.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 + || value.Contains('/') + || value.Contains('\\') + ) + { + diagnostic = "Asset name is invalid."; + return false; + } + + diagnostic = string.Empty; + return true; + } + + private static bool TryValidateFolder( + string value, + out string folder, + out string diagnostic + ) + { + folder = value; + DataVisualizerConfiguration configuration = new() { dataFolderPath = value }; + if (!configuration.TryValidate(out diagnostic)) + { + return false; + } + + folder = configuration.dataFolderPath; + if (!AssetDatabase.IsValidFolder(folder)) + { + diagnostic = "Move target must be an existing Assets folder."; + return false; + } + + return true; + } + + private static bool TryValidateMove( + string sourcePath, + string targetPath, + out string diagnostic + ) + { + if (string.IsNullOrWhiteSpace(targetPath)) + { + diagnostic = "Asset target path is invalid."; + return false; + } + + if (string.Equals(sourcePath, targetPath, StringComparison.OrdinalIgnoreCase)) + { + diagnostic = "Asset target path is unchanged."; + return false; + } + + diagnostic = AssetDatabase.ValidateMoveAsset(sourcePath, targetPath); + return string.IsNullOrWhiteSpace(diagnostic); + } + + private static DataVisualizerAssetOperationResult FailOperation( + DataVisualizerAssetOperationResult result, + string diagnostic + ) + { + result.complete = true; + result.succeeded = false; + result.diagnostic = diagnostic; + return result; + } + } +} diff --git a/Editor/DataVisualizer/Automation/DataVisualizerAutomationOperations.cs.meta b/Editor/DataVisualizer/Automation/DataVisualizerAutomationOperations.cs.meta new file mode 100644 index 0000000..bb46ab6 --- /dev/null +++ b/Editor/DataVisualizer/Automation/DataVisualizerAutomationOperations.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9d3e6b7a1c5f4e208a6d9b2c7f013548 diff --git a/Editor/DataVisualizer/Automation/DataVisualizerAutomationSerializedProperties.cs b/Editor/DataVisualizer/Automation/DataVisualizerAutomationSerializedProperties.cs new file mode 100644 index 0000000..2dc612d --- /dev/null +++ b/Editor/DataVisualizer/Automation/DataVisualizerAutomationSerializedProperties.cs @@ -0,0 +1,329 @@ +namespace WallstopStudios.DataVisualizer.Editor.Automation +{ + using UnityEditor; + using UnityEngine; + + public static partial class DataVisualizerAutomation + { + private static bool TryValidateSerializedProperty( + string assetPath, + string propertyPath, + DataVisualizerSerializedValue value, + out string diagnostic + ) + { + if ( + !TryGetSerializedProperty( + assetPath, + propertyPath, + out _, + out SerializedProperty property, + out diagnostic + ) + ) + { + return false; + } + + return TryAssignSerializedValue(property, value, out diagnostic); + } + + private static bool ApplySerializedProperty( + string assetPath, + string propertyPath, + DataVisualizerSerializedValue value, + out string diagnostic + ) + { + if ( + !TryGetSerializedProperty( + assetPath, + propertyPath, + out SerializedObject serializedObject, + out SerializedProperty property, + out diagnostic + ) + ) + { + return false; + } + + Undo.RecordObject( + serializedObject.targetObject, + $"Set serialized property {propertyPath}" + ); + if (!TryAssignSerializedValue(property, value, out diagnostic)) + { + return false; + } + + serializedObject.ApplyModifiedProperties(); + EditorUtility.SetDirty(serializedObject.targetObject); + return true; + } + + private static bool TryGetSerializedProperty( + string assetPath, + string propertyPath, + out SerializedObject serializedObject, + out SerializedProperty property, + out string diagnostic + ) + { + serializedObject = null; + property = null; + if (string.IsNullOrWhiteSpace(propertyPath)) + { + diagnostic = "propertyPath is required for serialized-property operations."; + return false; + } + + ScriptableObject asset = + AssetDatabase.LoadMainAssetAtPath(assetPath) as ScriptableObject; + if (asset == null) + { + diagnostic = "The requested ScriptableObject asset was not found."; + return false; + } + + serializedObject = new SerializedObject(asset); + property = serializedObject.FindProperty(propertyPath); + if (property == null) + { + diagnostic = $"Serialized property '{propertyPath}' was not found."; + return false; + } + + diagnostic = string.Empty; + return true; + } + + private static bool TryAssignSerializedValue( + SerializedProperty property, + DataVisualizerSerializedValue value, + out string diagnostic + ) + { + if (value == null) + { + diagnostic = "serializedValue is required for serialized-property operations."; + return false; + } + + switch (value.kind) + { + case DataVisualizerSerializedValueKind.String: + if (!RequireType(property, SerializedPropertyType.String, out diagnostic)) + { + return false; + } + property.stringValue = value.stringValue ?? string.Empty; + return true; + + case DataVisualizerSerializedValueKind.Boolean: + if (!RequireType(property, SerializedPropertyType.Boolean, out diagnostic)) + { + return false; + } + property.boolValue = value.boolValue; + return true; + + case DataVisualizerSerializedValueKind.Integer: + if (!RequireType(property, SerializedPropertyType.Integer, out diagnostic)) + { + return false; + } + property.intValue = value.intValue; + return true; + + case DataVisualizerSerializedValueKind.Long: + if (!RequireType(property, SerializedPropertyType.Integer, out diagnostic)) + { + return false; + } + property.longValue = value.longValue; + return true; + + case DataVisualizerSerializedValueKind.Float: + if (!RequireType(property, SerializedPropertyType.Float, out diagnostic)) + { + return false; + } + property.floatValue = value.floatValue; + return true; + + case DataVisualizerSerializedValueKind.Double: + if (!RequireType(property, SerializedPropertyType.Float, out diagnostic)) + { + return false; + } + property.doubleValue = value.doubleValue; + return true; + + case DataVisualizerSerializedValueKind.Enum: + if (!RequireType(property, SerializedPropertyType.Enum, out diagnostic)) + { + return false; + } + if (value.intValue < 0 || value.intValue >= property.enumDisplayNames.Length) + { + diagnostic = + "Enum value is outside the serialized property's declared values."; + return false; + } + property.enumValueIndex = value.intValue; + return true; + + case DataVisualizerSerializedValueKind.ObjectReferenceGuid: + if ( + !RequireType( + property, + SerializedPropertyType.ObjectReference, + out diagnostic + ) + ) + { + return false; + } + if (string.IsNullOrWhiteSpace(value.objectReferenceGuid)) + { + property.objectReferenceValue = null; + return true; + } + string referencePath = AssetDatabase.GUIDToAssetPath(value.objectReferenceGuid); + UnityEngine.Object reference = string.IsNullOrWhiteSpace(referencePath) + ? null + : AssetDatabase.LoadAssetAtPath(referencePath); + if (reference == null) + { + diagnostic = "The object-reference GUID was not found."; + return false; + } + property.objectReferenceValue = reference; + return true; + + case DataVisualizerSerializedValueKind.Color: + if (!RequireType(property, SerializedPropertyType.Color, out diagnostic)) + { + return false; + } + property.colorValue = value.colorValue; + return true; + + case DataVisualizerSerializedValueKind.Vector2: + if (!RequireType(property, SerializedPropertyType.Vector2, out diagnostic)) + { + return false; + } + property.vector2Value = value.vector2Value; + return true; + + case DataVisualizerSerializedValueKind.Vector3: + if (!RequireType(property, SerializedPropertyType.Vector3, out diagnostic)) + { + return false; + } + property.vector3Value = value.vector3Value; + return true; + + case DataVisualizerSerializedValueKind.Vector4: + if (!RequireType(property, SerializedPropertyType.Vector4, out diagnostic)) + { + return false; + } + property.vector4Value = value.vector4Value; + return true; + + case DataVisualizerSerializedValueKind.Quaternion: + if (!RequireType(property, SerializedPropertyType.Quaternion, out diagnostic)) + { + return false; + } + property.quaternionValue = value.quaternionValue; + return true; + + case DataVisualizerSerializedValueKind.Rect: + if (!RequireType(property, SerializedPropertyType.Rect, out diagnostic)) + { + return false; + } + property.rectValue = value.rectValue; + return true; + + case DataVisualizerSerializedValueKind.Bounds: + if (!RequireType(property, SerializedPropertyType.Bounds, out diagnostic)) + { + return false; + } + property.boundsValue = value.boundsValue; + return true; + + case DataVisualizerSerializedValueKind.Vector2Int: + if (!RequireType(property, SerializedPropertyType.Vector2Int, out diagnostic)) + { + return false; + } + property.vector2IntValue = value.vector2IntValue; + return true; + + case DataVisualizerSerializedValueKind.Vector3Int: + if (!RequireType(property, SerializedPropertyType.Vector3Int, out diagnostic)) + { + return false; + } + property.vector3IntValue = value.vector3IntValue; + return true; + + case DataVisualizerSerializedValueKind.RectInt: + if (!RequireType(property, SerializedPropertyType.RectInt, out diagnostic)) + { + return false; + } + property.rectIntValue = value.rectIntValue; + return true; + + case DataVisualizerSerializedValueKind.BoundsInt: + if (!RequireType(property, SerializedPropertyType.BoundsInt, out diagnostic)) + { + return false; + } + property.boundsIntValue = value.boundsIntValue; + return true; + + case DataVisualizerSerializedValueKind.ArraySize: + if (!RequireType(property, SerializedPropertyType.ArraySize, out diagnostic)) + { + return false; + } + if (value.intValue < 0) + { + diagnostic = "Array size cannot be negative."; + return false; + } + property.arraySize = value.intValue; + return true; + + default: + diagnostic = "The serialized value kind is unsupported."; + return false; + } + } + + private static bool RequireType( + SerializedProperty property, + SerializedPropertyType expected, + out string diagnostic + ) + { + if (property.propertyType != expected) + { + diagnostic = + $"Serialized property '{property.propertyPath}' is {property.propertyType}, expected {expected}."; + return false; + } + + diagnostic = string.Empty; + return true; + } + } +} diff --git a/Editor/DataVisualizer/Automation/DataVisualizerAutomationSerializedProperties.cs.meta b/Editor/DataVisualizer/Automation/DataVisualizerAutomationSerializedProperties.cs.meta new file mode 100644 index 0000000..4a39a96 --- /dev/null +++ b/Editor/DataVisualizer/Automation/DataVisualizerAutomationSerializedProperties.cs.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 8e1a3c5f7b9d2e4a6c8f0b1d3e5a7c9f +timeCreated: 1789002000 +licenseType: Pro +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/DataVisualizer/Data/DataVisualizerUserState.cs b/Editor/DataVisualizer/Data/DataVisualizerUserState.cs index 5c31168..1ca802e 100644 --- a/Editor/DataVisualizer/Data/DataVisualizerUserState.cs +++ b/Editor/DataVisualizer/Data/DataVisualizerUserState.cs @@ -32,22 +32,31 @@ public static DataVisualizerUserState FromJson(string json) return null; } - DataVisualizerUserState userState = JsonUtility.FromJson(json); - if (userState == null) + try { + DataVisualizerUserState userState = JsonUtility.FromJson( + json + ); + if (userState == null) + { + return userState; + } + + LegacyUserState legacyUserState = JsonUtility.FromJson(json); + if ( + string.IsNullOrWhiteSpace(userState.lastSelectedTypeFullName) + && !string.IsNullOrWhiteSpace(legacyUserState?.lastSelectedTypeName) + ) + { + userState.lastSelectedTypeFullName = legacyUserState.lastSelectedTypeName; + } + return userState; } - - LegacyUserState legacyUserState = JsonUtility.FromJson(json); - if ( - string.IsNullOrWhiteSpace(userState.lastSelectedTypeFullName) - && !string.IsNullOrWhiteSpace(legacyUserState?.lastSelectedTypeName) - ) + catch (ArgumentException) { - userState.lastSelectedTypeFullName = legacyUserState.lastSelectedTypeName; + return null; } - - return userState; } public void HydrateFrom(DataVisualizerSettings settings) diff --git a/Editor/DataVisualizer/DataVisualizer.cs b/Editor/DataVisualizer/DataVisualizer.cs index 8880e44..76febc5 100644 --- a/Editor/DataVisualizer/DataVisualizer.cs +++ b/Editor/DataVisualizer/DataVisualizer.cs @@ -11,7 +11,6 @@ namespace WallstopStudios.DataVisualizer.Editor using System.Linq; using System.Reflection; using System.Text; - using System.Text.RegularExpressions; using Data; using Extensions; using Search; @@ -27,6 +26,7 @@ namespace WallstopStudios.DataVisualizer.Editor using UnityEngine.UIElements; using Utilities; using Helper; + using WallstopStudios.DataVisualizer.Editor.Automation; using Debug = UnityEngine.Debug; using Object = UnityEngine.Object; @@ -70,8 +70,10 @@ public sealed class DataVisualizer : EditorWindow private const float MinWindowWidth = MinNamespacePaneWidth + MinObjectPaneWidth + MinInspectorPaneWidth + 60f; private const float MinWindowHeight = 480f; + private const long SplitterSaveDebounceMilliseconds = 250; private const int AsyncLoadBatchSize = 100; private const int AsyncLoadPriorityBatchSize = 100; + private const double AsyncContinuationDelaySeconds = 0.01d; // Debug logging for testing async loading // Set to true to see detailed loading performance logs in Unity Console @@ -275,6 +277,10 @@ private int HiddenNamespaces private Label _processorHeaderLabel; private readonly List _allDataProcessors = new(); private readonly List _compatibleDataProcessors = new(); + private bool _dataProcessorsDiscovered; + private bool _isPlayModeSuspended; + private bool _refreshQueuedDuringPlayMode; + private bool _deferredInitializationPending; private TextField _searchField; private VisualElement _searchPopover; @@ -303,6 +309,7 @@ private int HiddenNamespaces private float _lastSavedOuterWidth = -1f; private float _lastSavedInnerWidth = -1f; private IVisualElementScheduledItem _saveWidthsTask; + private bool _suppressSplitterWidthSave; private int _searchHighlightIndex = -1; private int _typePopoverHighlightIndex = -1; @@ -339,11 +346,12 @@ private int HiddenNamespaces // 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; + private bool _asyncContinuationRegistered; + private double _nextAsyncContinuationTime; // Total asset count for the in-progress async load, captured once from the // initial FindAssets so per-batch progress updates never rescan the project. @@ -400,11 +408,11 @@ public static void ShowWindow() 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 centerX = monitorArea.x + (monitorArea.width - width) / 2f; + float centerY = monitorArea.y + (monitorArea.height - height) / 2f; - float x = Mathf.Max(0, centerX); - float y = Mathf.Max(0, centerY); + float x = Mathf.Max(monitorArea.x, centerX); + float y = Mathf.Max(monitorArea.y, centerY); window.position = new Rect(x, y, width, height); EditorPrefs.SetBool(PrefsInitialSizeAppliedKey, true); @@ -416,6 +424,13 @@ private void OnEnable() _nextColorIndex = 0; Instance = this; _isSearchCachePopulated = false; + _dataProcessorsDiscovered = false; + _allDataProcessors.Clear(); + _isPlayModeSuspended = EditorApplication.isPlayingOrWillChangePlaymode; + _refreshQueuedDuringPlayMode = false; + _deferredInitializationPending = false; + EditorApplication.playModeStateChanged -= HandlePlayModeStateChanged; + EditorApplication.playModeStateChanged += HandlePlayModeStateChanged; _selectedObject = null; _selectedObjects.Clear(); #if ODIN_INSPECTOR @@ -423,29 +438,6 @@ private void OnEnable() #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( @@ -456,6 +448,7 @@ private void OnEnable() private void OnDisable() { + EditorApplication.playModeStateChanged -= HandlePlayModeStateChanged; rootVisualElement.UnregisterCallback( HandleGlobalKeyDown, TrickleDown.TrickleDown @@ -465,19 +458,20 @@ private void OnDisable() private void OnDestroy() { + EditorApplication.playModeStateChanged -= HandlePlayModeStateChanged; Cleanup(); } private void Cleanup() { + EditorApplication.playModeStateChanged -= HandlePlayModeStateChanged; if (Instance == this) { Instance = null; } // Cancel async loading - _asyncLoadTask?.Pause(); - _asyncLoadTask = null; + UnregisterAsyncContinuation(); _asyncLoadTargetType = null; _pendingObjectGuids.Clear(); _pendingSearchCacheGuids.Clear(); @@ -486,6 +480,11 @@ private void Cleanup() UpdateLoadingIndicator(0, 0); // Hide loading indicator _isLabelCachePopulated = false; + _dataProcessorsDiscovered = false; + _allDataProcessors.Clear(); + _isPlayModeSuspended = false; + _refreshQueuedDuringPlayMode = false; + _deferredInitializationPending = false; _selectedObject = null; _scriptableObjectTypes.Clear(); _namespaceOrder.Clear(); @@ -496,13 +495,11 @@ private void Cleanup() _isSearchCachePopulated = false; CloseActivePopover(); CancelDrag(); - _saveWidthsTask?.Pause(); + FlushSplitterWidthSave(); if (!Settings.persistStateInSettingsAsset && _userStateDirty) { SaveUserStateToFile(); } - - _saveWidthsTask = null; _currentInspectorScriptableObject?.Dispose(); _currentInspectorScriptableObject = null; _dragGhost?.RemoveFromHierarchy(); @@ -524,6 +521,111 @@ private void Cleanup() #endif } + private void HandlePlayModeStateChanged(PlayModeStateChange stateChange) + { + switch (stateChange) + { + case PlayModeStateChange.ExitingEditMode: + case PlayModeStateChange.EnteredPlayMode: + SuspendForPlayMode(); + break; + case PlayModeStateChange.EnteredEditMode: + ResumeAfterPlayMode(); + break; + } + } + + private void SuspendForPlayMode() + { + if (_isPlayModeSuspended) + { + ApplyPlayModeAvailability(); + return; + } + + _isPlayModeSuspended = true; + UnregisterAsyncContinuation(); + _saveWidthsTask?.Pause(); + _saveWidthsTask = null; + _suppressSplitterWidthSave = true; + CloseActivePopover(); + ApplyPlayModeAvailability(); + UpdateLoadingIndicator(0, 0); + } + + private void ResumeAfterPlayMode() + { + if (!_isPlayModeSuspended) + { + return; + } + + _isPlayModeSuspended = false; + ApplyPlayModeAvailability(); + + bool refreshQueued = _refreshQueuedDuringPlayMode; + _refreshQueuedDuringPlayMode = false; + if (_deferredInitializationPending) + { + _deferredInitializationPending = false; + CreateGUI(); + return; + } + + StartSplitterWidthTracking(); + if (refreshQueued) + { + ScheduleRefresh(); + } + + if (_isLoadingObjectsAsync && _asyncLoadTargetType != null) + { + ScheduleAsyncContinuation(); + } + + if (_isLoadingSearchCacheAsync) + { + ScheduleAsyncContinuation(); + } + else if (!_isSearchCachePopulated && _scriptableObjectTypes.Count > 0) + { + PopulateSearchCacheAsync(); + } + + UpdateLoadingIndicator(_selectedObjects.Count, _asyncLoadTotalCount); + } + + private void ApplyPlayModeAvailability() + { + bool enabled = !_isPlayModeSuspended; + _settingsButton?.SetEnabled(enabled); + _searchField?.SetEnabled(enabled); + _addTypeButton?.SetEnabled(enabled); + _addTypesFromDataFolderButton?.SetEnabled(enabled); + _addTypesFromScriptFolderButton?.SetEnabled(enabled); + _createObjectButton?.SetEnabled(enabled); + _typeAddSearchField?.SetEnabled(enabled); + _typeSearchField?.SetEnabled(enabled); + _namespaceListContainer?.SetEnabled(enabled); + _objectListView?.SetEnabled(enabled); + _labelCollapseRow?.SetEnabled(enabled); + _labelFilterSelectionRoot?.SetEnabled(enabled); + _inspectorContainer?.SetEnabled(enabled); + _processorAreaElement?.SetEnabled(enabled); + _settingsPopover?.SetEnabled(enabled); + _createPopover?.SetEnabled(enabled); + _renamePopover?.SetEnabled(enabled); + _confirmDeletePopover?.SetEnabled(enabled); + _confirmActionPopover?.SetEnabled(enabled); + _typeAddPopover?.SetEnabled(enabled); + _confirmNamespaceAddPopover?.SetEnabled(enabled); + _inspectorLabelSuggestionsPopover?.SetEnabled(enabled); + + rootVisualElement + .Query