From ebbfe311eea26e6c7161f1bee48bde094a8367db Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Thu, 12 Mar 2026 06:24:57 +0000 Subject: [PATCH] added asset workspace working with packer --- docs/packer/Prometeu Packer.md | 13 +- docs/packer/learn/mental-model-packer.md | 2 +- ...ain and Artifact Boundary Specification.md | 2 +- ...istry, and Asset Identity Specification.md | 37 +- ...nd Virtual Asset Contract Specification.md | 2 +- ...and Deterministic Packing Specification.md | 2 +- ...s, and Studio Integration Specification.md | 14 +- .../4. Assets Workspace Specification.md | 68 +- .../p/packer/api/assets/PackerAssetState.java | 5 +- .../packer/api/assets/PackerAssetSummary.java | 8 + .../api/assets/PackerBuildParticipation.java | 6 + .../api/mutations/PackerMutationType.java | 5 +- .../p/packer/building/PackerBuildPlanner.java | 10 +- .../PackerAssetDetailsService.java | 17 +- .../doctor/FileSystemPackerDoctorService.java | 30 +- .../FileSystemPackerRegistryRepository.java | 15 +- .../foundation/PackerRegistryEntry.java | 7 +- .../FileSystemPackerMutationService.java | 105 ++- .../FileSystemPackerWorkspaceService.java | 11 +- .../PackerAssetDetailsServiceTest.java | 17 +- .../FileSystemPackerDoctorServiceTest.java | 4 +- .../FileSystemPackerMutationServiceTest.java | 55 +- .../FileSystemPackerWorkspaceServiceTest.java | 10 +- .../shell/StudioWorkspaceRailControl.java | 8 +- .../java/p/studio/utilities/i18n/I18n.java | 72 +- .../p/studio/window/NewProjectWizard.java | 5 + .../p/studio/window/ProjectLauncherView.java | 4 + .../assets/AssetCreationCatalog.java | 67 ++ .../assets/AssetCreationRequest.java | 27 + .../assets/AssetCreationResult.java | 14 + .../assets/AssetCreationService.java | 7 + .../assets/AssetCreationWizard.java | 354 +++++++++ .../assets/AssetNavigatorFilter.java | 4 +- .../AssetNavigatorProjectionBuilder.java | 10 +- .../AssetRelocationTargetValidator.java | 113 +++ .../assets/AssetRelocationWizard.java | 417 +++++++++++ .../assets/AssetRootValidationResult.java | 25 + .../workspaces/assets/AssetRootValidator.java | 60 ++ .../workspaces/assets/AssetWorkspace.java | 687 ++++++++++++++---- .../assets/AssetWorkspaceAction.java | 7 +- .../AssetWorkspaceActionSetBuilder.java | 21 +- .../assets/AssetWorkspaceAssetState.java | 4 +- .../assets/AssetWorkspaceAssetSummary.java | 9 +- .../AssetWorkspaceBuildParticipation.java | 6 + .../assets/AssetWorkspaceMutationService.java | 15 +- ...leSystemAssetWorkspaceMutationService.java | 139 ++-- .../FileSystemAssetWorkspaceService.java | 37 +- .../PackerBackedAssetCreationService.java | 84 +++ ...erBackedAssetWorkspaceMutationService.java | 41 +- .../PackerBackedAssetWorkspaceService.java | 17 +- .../workspaces/builder/BuilderWorkspace.java | 2 + .../workspaces/editor/EditorToolbar.java | 5 +- .../main/resources/i18n/messages.properties | 76 +- .../resources/themes/default-prometeu.css | 324 ++++++--- .../shell/StudioActivityEventMapperTest.java | 4 +- .../assets/AssetCreationCatalogTest.java | 22 + .../AssetNavigatorProjectionBuilderTest.java | 34 +- .../AssetWorkspaceActionSetBuilderTest.java | 36 +- ...tWorkspaceMutationImpactViewModelTest.java | 3 +- .../assets/AssetWorkspaceStateTest.java | 22 +- ...stemAssetWorkspaceMutationServiceTest.java | 82 ++- .../FileSystemAssetWorkspaceServiceTest.java | 18 +- .../PackerBackedAssetCreationServiceTest.java | 95 +++ ...ckedAssetWorkspaceMutationServiceTest.java | 38 +- ...PackerBackedAssetWorkspaceServiceTest.java | 4 +- .../assets/PackerStudioIntegrationTest.java | 2 +- .../main/assets/.prometeu/index.json | 19 +- test-projects/main/assets/sound/asset.json | 13 + .../ui/{atlas-relocated => atlas2}/asset.json | 0 .../sprites/confirm.png | Bin .../main/assets/ui/one-more-atlas/asset.json | 13 + 71 files changed, 2800 insertions(+), 711 deletions(-) create mode 100644 prometeu-packer/src/main/java/p/packer/api/assets/PackerBuildParticipation.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationCatalog.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationRequest.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationResult.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationService.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationWizard.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRelocationTargetValidator.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRelocationWizard.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRootValidationResult.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRootValidator.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceBuildParticipation.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetCreationService.java create mode 100644 prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetCreationCatalogTest.java create mode 100644 prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetCreationServiceTest.java create mode 100644 test-projects/main/assets/sound/asset.json rename test-projects/main/assets/ui/{atlas-relocated => atlas2}/asset.json (100%) rename test-projects/main/assets/ui/{atlas-relocated => atlas2}/sprites/confirm.png (100%) create mode 100644 test-projects/main/assets/ui/one-more-atlas/asset.json diff --git a/docs/packer/Prometeu Packer.md b/docs/packer/Prometeu Packer.md index 39142fb0..0bc82cfc 100644 --- a/docs/packer/Prometeu Packer.md +++ b/docs/packer/Prometeu Packer.md @@ -404,27 +404,20 @@ Variants: * `add --dir` creates a dedicated asset root dir * `add --in-place` anchors next to the file -### 13.3 `prometeu packer adopt` - -Scans workspace for unregistered `asset.json` anchors and offers to register them. - -* default: dry-run list -* `--apply` registers them - -### 13.4 `prometeu packer forget ` +### 13.3 `prometeu packer forget ` Removes an asset from the registry without deleting files. Useful for WIP and cleanup. -### 13.5 `prometeu packer rm [--delete]` +### 13.4 `prometeu packer rm [--delete]` Removes the asset from the registry. * default: no deletion * `--delete` can remove the asset root dir (dangerous; must confirm in UI tooling, or require a force flag in CLI) -### 13.6 `prometeu packer list` +### 13.5 `prometeu packer list` Lists managed assets: diff --git a/docs/packer/learn/mental-model-packer.md b/docs/packer/learn/mental-model-packer.md index 872dbb9b..79e713e8 100644 --- a/docs/packer/learn/mental-model-packer.md +++ b/docs/packer/learn/mental-model-packer.md @@ -26,7 +26,7 @@ There are two different truths for two different jobs: - each `asset.json` tells the packer what that asset declares locally. This split matters. -Without it, copy/move/adopt flows become ambiguous very quickly. +Without it, copy/move/register flows become ambiguous very quickly. ## 3. What is the difference between `asset_id` and `asset_name`? diff --git a/docs/packer/specs/1. Domain and Artifact Boundary Specification.md b/docs/packer/specs/1. Domain and Artifact Boundary Specification.md index e420351f..4cd9dc21 100644 --- a/docs/packer/specs/1. Domain and Artifact Boundary Specification.md +++ b/docs/packer/specs/1. Domain and Artifact Boundary Specification.md @@ -14,7 +14,7 @@ Runtime-side reading semantics for `assets.pa` are defined upstream in `../runti - project workspace with `assets/` - packer registry and control data under `assets/.prometeu/` -- managed asset roots anchored by `asset.json` +- registered asset roots anchored by `asset.json` ## Core Rules diff --git a/docs/packer/specs/2. Workspace, Registry, and Asset Identity Specification.md b/docs/packer/specs/2. Workspace, Registry, and Asset Identity Specification.md index a6afd363..6d888959 100644 --- a/docs/packer/specs/2. Workspace, Registry, and Asset Identity Specification.md +++ b/docs/packer/specs/2. Workspace, Registry, and Asset Identity Specification.md @@ -1,7 +1,7 @@ # Workspace, Registry, and Asset Identity Specification Status: Draft -Scope: Managed asset roots, registry authority, and stable identity +Scope: Registered asset roots, registry authority, and stable identity Purpose: Define how the packer recognizes, tracks, and preserves asset identity. ## Authority and Precedence @@ -16,18 +16,22 @@ This specification consolidates the initial packer agenda and decision wave into ## Core Rules -1. One managed asset equals one asset root directory. +1. One registered asset equals one asset root directory. 2. One asset root contains exactly one anchor `asset.json`. -3. `assets/.prometeu/index.json` is the authoritative registry of managed assets. +3. `assets/.prometeu/index.json` is the authoritative registry of registered assets. 4. `asset.json` is the authoritative asset-local declaration. -5. An asset root absent from the registry is not part of the managed build set. +5. An asset root absent from the registry is `unregistered`. +6. `unregistered` assets are always `excluded` from build participation. +7. Registered assets may be `included` or `excluded` from build participation without losing identity. +8. The baseline build set includes registered assets whose registry entry is marked as build-included. ## Identity Model -Each managed asset has: +Each registered asset has: - `asset_id`: stable project-local identity - `asset_uuid`: stable long-lived identity for migration/tooling scenarios +- `included_in_build`: build participation flag persisted in the registry The following are not primary identity: @@ -50,15 +54,26 @@ Rules: Renaming `asset_name` is an API-visible change, but not an identity change. -## Orphan Anchors +## Unregistered Roots -An `asset.json` without a corresponding registry entry is an orphan declaration. +An `asset.json` without a corresponding registry entry is an unregistered asset root. Rules: -- it does not enter the build automatically; +- it is excluded from the build automatically; - it is diagnosable; -- it is adoptable only through explicit flow. +- it becomes registered only through explicit flow. + +## Build Participation + +Build participation is registry-owned for registered assets. + +Rules: + +- excluding a registered asset from builds does not remove `asset_id`; +- excluding a registered asset from builds does not remove `asset_uuid`; +- inclusion changes must preserve asset identity and registry location; +- unregistered assets cannot be marked as build-included. ## Asset ID Allocation @@ -77,7 +92,7 @@ Identity-bearing conflicts are structural errors. Examples: -- duplicate or ambiguous anchors under managed expectations; +- duplicate or ambiguous anchors under registered expectations; - manual copy that creates identity collision; - registered root missing anchor. @@ -91,6 +106,6 @@ Examples: This specification is complete enough when: -- managed asset boundaries are unambiguous; +- registered asset boundaries are unambiguous; - registry authority is explicit; - identity survives relocation without ambiguity. diff --git a/docs/packer/specs/3. Asset Declaration and Virtual Asset Contract Specification.md b/docs/packer/specs/3. Asset Declaration and Virtual Asset Contract Specification.md index 59a2e521..0ae59846 100644 --- a/docs/packer/specs/3. Asset Declaration and Virtual Asset Contract Specification.md +++ b/docs/packer/specs/3. Asset Declaration and Virtual Asset Contract Specification.md @@ -92,7 +92,7 @@ Rules: ## Preload -Each managed asset must declare preload intent explicitly. +Each registered asset must declare preload intent explicitly. Baseline shape: diff --git a/docs/packer/specs/4. Build Artifacts and Deterministic Packing Specification.md b/docs/packer/specs/4. Build Artifacts and Deterministic Packing Specification.md index e74e8183..42ea1c5c 100644 --- a/docs/packer/specs/4. Build Artifacts and Deterministic Packing Specification.md +++ b/docs/packer/specs/4. Build Artifacts and Deterministic Packing Specification.md @@ -74,7 +74,7 @@ The header carries: Rules: -- one entry per managed asset in the current build set; +- one entry per included asset in the current build set; - no secondary runtime-only identity layer; - no synthetic dense reindexing layer; - `asset_name` remains present as logical/API-facing metadata. diff --git a/docs/packer/specs/5. Diagnostics, Operations, and Studio Integration Specification.md b/docs/packer/specs/5. Diagnostics, Operations, and Studio Integration Specification.md index b712da6b..298ce979 100644 --- a/docs/packer/specs/5. Diagnostics, Operations, and Studio Integration Specification.md +++ b/docs/packer/specs/5. Diagnostics, Operations, and Studio Integration Specification.md @@ -12,12 +12,12 @@ This specification consolidates the initial packer agenda and decision wave into Diagnostics are divided into: -- structural managed-world errors; +- structural registered-world errors; - advisory workspace hygiene diagnostics. Rules: -- structural managed-world errors block builds; +- structural registered-world errors block builds; - hygiene findings do not block baseline builds by default; - workspace scanning is broader than build validation. @@ -25,7 +25,7 @@ Rules: Baseline doctor behavior: -- managed-world validation by default; +- registered-world validation by default; - broader workspace hygiene scanning in expanded workspace mode; - safe mechanical fix application only for baseline fix flows. @@ -38,7 +38,7 @@ Rules: - destructive or relocational mutations require explicit consent; - quarantine is explicit and reversible; - duplicate content detection is advisory, not structurally invalid by itself; -- orphan anchors are diagnosable, not build-active. +- unregistered asset roots are diagnosable and remain excluded from builds. ## Studio-First Service Surface @@ -48,8 +48,8 @@ Baseline core services: - `init_workspace` - `register_asset` -- `adopt_asset` -- `forget_asset` +- `include_asset_in_build` +- `exclude_asset_from_build` - `remove_asset` - `list_assets` - `get_asset_details` @@ -92,7 +92,7 @@ Rules: - preview or analysis precedes broad mutation where appropriate; - safe-fix flows are modeled as services, not merely terminal flags; -- `forget_asset` and `remove_asset` remain distinct operations. +- `exclude_asset_from_build` and `remove_asset` remain distinct operations. ## Structured Responses diff --git a/docs/studio/specs/4. Assets Workspace Specification.md b/docs/studio/specs/4. Assets Workspace Specification.md index d8e6d1f2..2c6e6ada 100644 --- a/docs/studio/specs/4. Assets Workspace Specification.md +++ b/docs/studio/specs/4. Assets Workspace Specification.md @@ -45,8 +45,8 @@ The `Assets` workspace is a Studio view over packer services. The workspace must assume: -- managed assets are the primary unit of identity and operation; -- orphan declarations may exist and must remain visible; +- registered and unregistered assets may coexist inside `assets/`; +- unregistered assets remain visible but excluded from builds; - assets may aggregate many internal inputs; - runtime-facing output contract data exists for each asset; - diagnostics, activity, progress, and staged mutation responses are available from Studio-facing services. @@ -62,7 +62,7 @@ The `Assets` workspace is: The workspace must help the user understand: -- what the managed asset is; +- what the asset registration and build status are; - where the asset root lives; - which internal inputs belong to that asset; - what the asset declares toward the runtime-facing contract; @@ -93,7 +93,8 @@ Filesystem structure may be visible and actionable as supporting context, but it - The left navigation surface must be a custom `Asset Tree` / `Asset Navigator`. - Asset nodes must expose strong visual semantics through icons, badges, and state styling. - Asset nodes must surface: - - managed/orphan state, + - registration status, + - build participation, - diagnostics presence, - preload intent, - and broad asset family or type where available. @@ -102,8 +103,8 @@ Filesystem structure may be visible and actionable as supporting context, but it ### Filters and Search - The baseline filters are: - - `Managed` - - `Orphan` + - `Registered` + - `Unregistered` - `Diagnostics` - `Preload` - The navigator must support textual search. @@ -112,11 +113,22 @@ Filesystem structure may be visible and actionable as supporting context, but it - asset root path - path context +### Workspace Actions + +- Workspace-scoped actions must appear above the main asset workspace split, not inside the navigator list region. +- Workspace-scoped actions must not be mixed into the selected-asset details surface. +- Workspace actions should remain visible without requiring a selected asset. +- Global asset-manager actions such as `Add Asset`, `Doctor`, and `Pack` belong to this workspace-level action area. + +### Navigator Actions + +- Navigator-local controls may include search and filters that are specifically about asset discovery and navigation. + ### Selection Rules - The baseline navigator is single-select. -- Managed asset selection must be preserved by `asset_id`. -- Orphan selection must be preserved by asset root path until the asset becomes managed. +- Registered asset selection must be preserved by `asset_id`. +- Unregistered selection must be preserved by asset root path until the asset becomes registered. - If an asset changes state while preserving identity, selection must remain attached to it. - If the selected asset is removed from the navigator, selection must clear explicitly. - Selection must never silently drift to another asset due to refresh ordering. @@ -127,8 +139,8 @@ Filesystem structure may be visible and actionable as supporting context, but it - The navigator must define explicit `no assets` state. - The navigator must define explicit `no results` state. - The navigator must define explicit `workspace error` state. -- Orphan assets must appear in the same navigator flow as managed assets. -- Orphan styling must communicate `declared, not managed`, not `broken`. +- Unregistered assets must appear in the same navigator flow as registered assets. +- Unregistered styling must communicate `present but not yet registered`, not `broken`. ### Inputs Expansion @@ -137,22 +149,22 @@ Filesystem structure may be visible and actionable as supporting context, but it ## Selected Asset Details Rules -The selected asset view must use this fixed section order: +The selected asset view must use this stable composition: -1. `Summary` +1. top row with `Summary` and `Actions` side by side 2. `Runtime Contract` 3. `Inputs / Preview` 4. `Diagnostics` -5. `Actions` -This section order is stable across asset families. +This composition is stable across asset families. ### Summary - `Summary` must always be present for a selected asset. - `Summary` must show: - `asset_name` - - managed/orphan state + - registration status + - build participation - `asset_id` when available - family/type - asset root path @@ -187,16 +199,21 @@ This section order is stable across asset families. ### Actions - Actions must be explicit. +- Actions that target the selected asset must appear beside `Summary`, not in the navigator area. - Safe and routine actions must be visually separated from sensitive mutations. - Hidden or automatic repair behavior is not allowed in the selected-asset view. ## Action Rules -- Primary actions for managed assets include `Doctor` and `Build`. -- Primary actions for orphan assets include `Adopt`. -- `Register` must remain available when explicit registration is the correct flow. -- Sensitive actions such as `Forget`, `Remove`, and quarantine-like actions must be visually separated from routine actions. +- Registered assets excluded from builds must expose an explicit `Include In Build` action. +- Primary actions for unregistered assets include `Register`. +- `Register` is a direct action in Studio UX. If registration is valid, clicking it must register immediately without an extra acknowledgement step. +- If registration is blocked, Studio may surface the blocking preview inline so the user can inspect why registration cannot proceed. +- Sensitive actions such as `Exclude From Build`, `Remove`, and `Relocate` must be visually separated from routine actions. - Sensitive actions must not look interchangeable with routine actions. +- `Relocate` must collect an explicit user-chosen destination root before the staged preview is generated. +- Automatic relocation targets are not sufficient Studio UX for `Relocate`. +- `Exclude From Build` and `Remove` must confirm in a modal review surface rather than an inline staged panel. ## Activity, Progress, and Logs Rules @@ -230,24 +247,25 @@ This section order is stable across asset families. Sensitive asset mutations are `preview-first`. -The default review surface is an inline staged panel inside the workspace. +The review surface depends on the mutation class. -Modal confirmation is reserved for high-risk final commits such as destructive delete or major relocation. +Inline staged panels are allowed for non-destructive blocked or inspect-only flows. + +Modal review and confirmation is required for `Exclude From Build`, `Remove`, and relocation commits. ### Mutations Requiring Preview -- `Adopt` -- `Forget` +- `Exclude From Build` - `Remove` -- `Quarantine` - relocational changes such as move or rename of asset roots - batch operations -`Doctor` and `Build` remain outside this sensitive mutation staging flow. +Navigator-level `Doctor` and `Pack`, plus asset-level `Include In Build` and successful `Register`, remain outside this sensitive mutation staging flow. ### Preview Content - Preview must show affected assets. +- Relocation preview must show the planned target root chosen by the user. - Preview must show proposed actions. - Preview must distinguish registry impact from workspace impact. - Preview must show blockers, warnings, and safe fixes as separate visual sections. diff --git a/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetState.java b/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetState.java index a530c36e..58d2d9bc 100644 --- a/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetState.java +++ b/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetState.java @@ -1,7 +1,6 @@ package p.packer.api.assets; public enum PackerAssetState { - MANAGED, - ORPHAN, - INVALID + REGISTERED, + UNREGISTERED } diff --git a/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetSummary.java b/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetSummary.java index 1ebb01f4..9dca8095 100644 --- a/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetSummary.java +++ b/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetSummary.java @@ -5,6 +5,7 @@ import java.util.Objects; public record PackerAssetSummary( PackerAssetIdentity identity, PackerAssetState state, + PackerBuildParticipation buildParticipation, String assetFamily, boolean preloadEnabled, boolean hasDiagnostics) { @@ -12,9 +13,16 @@ public record PackerAssetSummary( public PackerAssetSummary { Objects.requireNonNull(identity, "identity"); Objects.requireNonNull(state, "state"); + Objects.requireNonNull(buildParticipation, "buildParticipation"); assetFamily = Objects.requireNonNullElse(assetFamily, "unknown").trim(); if (assetFamily.isBlank()) { assetFamily = "unknown"; } + if (state == PackerAssetState.REGISTERED && identity.assetId() == null) { + throw new IllegalArgumentException("registered asset must expose assetId"); + } + if (state == PackerAssetState.UNREGISTERED && buildParticipation != PackerBuildParticipation.EXCLUDED) { + throw new IllegalArgumentException("unregistered asset must stay excluded from build participation"); + } } } diff --git a/prometeu-packer/src/main/java/p/packer/api/assets/PackerBuildParticipation.java b/prometeu-packer/src/main/java/p/packer/api/assets/PackerBuildParticipation.java new file mode 100644 index 00000000..98bfae68 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/api/assets/PackerBuildParticipation.java @@ -0,0 +1,6 @@ +package p.packer.api.assets; + +public enum PackerBuildParticipation { + INCLUDED, + EXCLUDED +} diff --git a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationType.java b/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationType.java index 0e9203d5..f8d20400 100644 --- a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationType.java +++ b/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationType.java @@ -2,9 +2,8 @@ package p.packer.api.mutations; public enum PackerMutationType { REGISTER_ASSET, - ADOPT_ASSET, - FORGET_ASSET, + INCLUDE_ASSET_IN_BUILD, + EXCLUDE_ASSET_FROM_BUILD, REMOVE_ASSET, - QUARANTINE_ASSET, RELOCATE_ASSET } diff --git a/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanner.java b/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanner.java index a58fb00b..cac7768e 100644 --- a/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanner.java +++ b/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanner.java @@ -2,6 +2,7 @@ package p.packer.building; import p.packer.api.PackerOperationStatus; import p.packer.api.PackerProjectContext; +import p.packer.api.assets.PackerBuildParticipation; import p.packer.api.assets.PackerAssetState; import p.packer.api.diagnostics.PackerDiagnostic; import p.packer.api.diagnostics.PackerDiagnosticCategory; @@ -45,16 +46,17 @@ public final class PackerBuildPlanner { final List plannedAssets = new ArrayList<>(); snapshot.assets().stream() - .filter(asset -> asset.state() == PackerAssetState.MANAGED) + .filter(asset -> asset.buildParticipation() == PackerBuildParticipation.INCLUDED) .sorted(Comparator.comparingInt(asset -> asset.identity().assetId())) .forEach(asset -> { final var detailsResult = detailsService.getAssetDetails(new GetAssetDetailsRequest(project, Integer.toString(asset.identity().assetId()))); diagnostics.addAll(detailsResult.diagnostics()); - if (detailsResult.details().summary().state() != PackerAssetState.MANAGED) { + if (detailsResult.details().summary().state() != PackerAssetState.REGISTERED + || detailsResult.details().summary().buildParticipation() != PackerBuildParticipation.INCLUDED) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.BUILD, - "Managed asset is not build-eligible: " + asset.identity().assetName(), + "Registered asset is not build-eligible: " + asset.identity().assetName(), asset.identity().assetRoot(), true)); return; @@ -115,7 +117,7 @@ public final class PackerBuildPlanner { "inputs", plannedAssets.stream().map(this::cacheKeyView).toList()))); return new PackerBuildPlanResult( diagnostics.isEmpty() ? PackerOperationStatus.SUCCESS : PackerOperationStatus.PARTIAL, - "Build plan ready for " + plannedAssets.size() + " managed assets.", + "Build plan ready for " + plannedAssets.size() + " included assets.", new PackerBuildPlan(cacheKey, plannedAssets, assetTableJson, preloadJson), List.copyOf(diagnostics)); } diff --git a/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDetailsService.java b/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDetailsService.java index eb46f26e..cebbcf91 100644 --- a/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDetailsService.java +++ b/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDetailsService.java @@ -6,6 +6,7 @@ import p.packer.api.assets.PackerAssetDetails; import p.packer.api.assets.PackerAssetIdentity; import p.packer.api.assets.PackerAssetState; import p.packer.api.assets.PackerAssetSummary; +import p.packer.api.assets.PackerBuildParticipation; import p.packer.api.diagnostics.PackerDiagnostic; import p.packer.api.diagnostics.PackerDiagnosticCategory; import p.packer.api.diagnostics.PackerDiagnosticSeverity; @@ -65,13 +66,19 @@ public final class PackerAssetDetailsService { } final PackerAssetDeclaration declaration = parsed.declaration(); + final PackerAssetState state = resolved.registryEntry().isPresent() + ? PackerAssetState.REGISTERED + : PackerAssetState.UNREGISTERED; final PackerAssetSummary summary = new PackerAssetSummary( new PackerAssetIdentity( resolved.registryEntry().map(PackerRegistryEntry::assetId).orElse(null), resolved.registryEntry().map(PackerRegistryEntry::assetUuid).orElse(null), declaration.name(), resolved.assetRoot()), - resolved.registryEntry().isPresent() ? PackerAssetState.MANAGED : PackerAssetState.ORPHAN, + state, + resolved.registryEntry().map(entry -> entry.includedInBuild() + ? PackerBuildParticipation.INCLUDED + : PackerBuildParticipation.EXCLUDED).orElse(PackerBuildParticipation.EXCLUDED), declaration.type(), declaration.preloadEnabled(), !diagnostics.isEmpty()); @@ -89,13 +96,19 @@ public final class PackerAssetDetailsService { } private GetAssetDetailsResult failureResult(ResolvedAssetReference resolved, List diagnostics) { + final PackerAssetState state = resolved.registryEntry().isPresent() + ? PackerAssetState.REGISTERED + : PackerAssetState.UNREGISTERED; final PackerAssetSummary summary = new PackerAssetSummary( new PackerAssetIdentity( resolved.registryEntry().map(PackerRegistryEntry::assetId).orElse(null), resolved.registryEntry().map(PackerRegistryEntry::assetUuid).orElse(null), resolved.assetRoot().getFileName().toString(), resolved.assetRoot()), - PackerAssetState.INVALID, + state, + resolved.registryEntry().map(entry -> entry.includedInBuild() + ? PackerBuildParticipation.INCLUDED + : PackerBuildParticipation.EXCLUDED).orElse(PackerBuildParticipation.EXCLUDED), "unknown", false, true); diff --git a/prometeu-packer/src/main/java/p/packer/doctor/FileSystemPackerDoctorService.java b/prometeu-packer/src/main/java/p/packer/doctor/FileSystemPackerDoctorService.java index ec201002..f4966508 100644 --- a/prometeu-packer/src/main/java/p/packer/doctor/FileSystemPackerDoctorService.java +++ b/prometeu-packer/src/main/java/p/packer/doctor/FileSystemPackerDoctorService.java @@ -86,38 +86,29 @@ public final class FileSystemPackerDoctorService implements PackerDoctorService addDiagnostic(diagnostics, seenDiagnostics, diagnostic); } - if (doctorRequest.mode() == PackerDoctorMode.EXPANDED_WORKSPACE && asset.state() == PackerAssetState.ORPHAN) { + if (doctorRequest.mode() == PackerDoctorMode.EXPANDED_WORKSPACE && asset.state() == PackerAssetState.UNREGISTERED) { addDiagnostic(diagnostics, seenDiagnostics, new PackerDiagnostic( PackerDiagnosticSeverity.WARNING, PackerDiagnosticCategory.HYGIENE, - "Orphan asset is valid but not registered in the managed build set: " + relativeAssetRoot(project, asset.identity().assetRoot()), + "Asset is present in assets/ but not registered, so it stays excluded from builds: " + relativeAssetRoot(project, asset.identity().assetRoot()), asset.identity().assetRoot(), false)); - if (doctorRequest.includeSafeFixes() && details.summary().state() != PackerAssetState.INVALID) { + if (doctorRequest.includeSafeFixes() && details.diagnostics().stream().noneMatch(PackerDiagnostic::blocking)) { safeFixes.add("register_asset " + relativeAssetRoot(project, asset.identity().assetRoot())); } } - if (doctorRequest.mode() == PackerDoctorMode.EXPANDED_WORKSPACE && isInsideQuarantine(project, asset.identity().assetRoot())) { - addDiagnostic(diagnostics, seenDiagnostics, new PackerDiagnostic( - PackerDiagnosticSeverity.INFO, - PackerDiagnosticCategory.HYGIENE, - "Asset root is currently quarantined and excluded from the active workspace surface.", - asset.identity().assetRoot(), - false)); - } - details.inputsByRole().forEach((role, inputs) -> inputs.forEach(input -> { if (Files.isRegularFile(input)) { return; } - final boolean managed = asset.state() == PackerAssetState.MANAGED; + final boolean registered = asset.state() == PackerAssetState.REGISTERED; addDiagnostic(diagnostics, seenDiagnostics, new PackerDiagnostic( - managed ? PackerDiagnosticSeverity.ERROR : PackerDiagnosticSeverity.WARNING, - managed ? PackerDiagnosticCategory.STRUCTURAL : PackerDiagnosticCategory.HYGIENE, + registered ? PackerDiagnosticSeverity.ERROR : PackerDiagnosticSeverity.WARNING, + registered ? PackerDiagnosticCategory.STRUCTURAL : PackerDiagnosticCategory.HYGIENE, "Declared input is missing for role '" + role + "': " + relativeEvidence(project, input), input, - managed)); + registered)); })); events.emit( PackerEventKind.PROGRESS_UPDATED, @@ -143,7 +134,7 @@ public final class FileSystemPackerDoctorService implements PackerDoctorService if (mode == PackerDoctorMode.EXPANDED_WORKSPACE) { return true; } - return state == PackerAssetState.MANAGED || state == PackerAssetState.INVALID; + return state == PackerAssetState.REGISTERED; } private void addDiagnostic(List diagnostics, Set seenDiagnostics, PackerDiagnostic diagnostic) { @@ -153,11 +144,6 @@ public final class FileSystemPackerDoctorService implements PackerDoctorService } } - private boolean isInsideQuarantine(PackerProjectContext project, Path assetRoot) { - return assetRoot.toAbsolutePath().normalize() - .startsWith(project.rootPath().resolve("assets/.prometeu/quarantine").toAbsolutePath().normalize()); - } - private String relativeAssetRoot(PackerProjectContext project, Path assetRoot) { return project.rootPath().resolve("assets").toAbsolutePath().normalize() .relativize(assetRoot.toAbsolutePath().normalize()) diff --git a/prometeu-packer/src/main/java/p/packer/foundation/FileSystemPackerRegistryRepository.java b/prometeu-packer/src/main/java/p/packer/foundation/FileSystemPackerRegistryRepository.java index a000a674..28d8cdce 100644 --- a/prometeu-packer/src/main/java/p/packer/foundation/FileSystemPackerRegistryRepository.java +++ b/prometeu-packer/src/main/java/p/packer/foundation/FileSystemPackerRegistryRepository.java @@ -37,7 +37,11 @@ public final class FileSystemPackerRegistryRepository implements PackerRegistryR if (asset == null) { continue; } - entries.add(new PackerRegistryEntry(asset.assetId, asset.assetUuid, normalizeRoot(asset.root))); + entries.add(new PackerRegistryEntry( + asset.assetId, + asset.assetUuid, + normalizeRoot(asset.root), + asset.includedInBuild == null || asset.includedInBuild)); } } validateEntries(entries); @@ -64,7 +68,11 @@ public final class FileSystemPackerRegistryRepository implements PackerRegistryR document.schemaVersion = state.schemaVersion(); document.nextAssetId = state.nextAssetId(); document.assets = state.assets().stream() - .map(entry -> new RegistryAssetDocument(entry.assetId(), entry.assetUuid(), entry.root())) + .map(entry -> new RegistryAssetDocument( + entry.assetId(), + entry.assetUuid(), + entry.root(), + entry.includedInBuild())) .toList(); MAPPER.writerWithDefaultPrettyPrinter().writeValue(registryPath.toFile(), document); } catch (IOException exception) { @@ -121,6 +129,7 @@ public final class FileSystemPackerRegistryRepository implements PackerRegistryR private record RegistryAssetDocument( @JsonProperty("asset_id") int assetId, @JsonProperty("asset_uuid") String assetUuid, - @JsonProperty("root") String root) { + @JsonProperty("root") String root, + @JsonProperty("included_in_build") Boolean includedInBuild) { } } diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryEntry.java b/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryEntry.java index ae56a570..b3a539a3 100644 --- a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryEntry.java +++ b/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryEntry.java @@ -5,7 +5,12 @@ import java.util.Objects; public record PackerRegistryEntry( int assetId, String assetUuid, - String root) { + String root, + boolean includedInBuild) { + + public PackerRegistryEntry(int assetId, String assetUuid, String root) { + this(assetId, assetUuid, root, true); + } public PackerRegistryEntry { assetUuid = Objects.requireNonNull(assetUuid, "assetUuid").trim(); diff --git a/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java b/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java index b64b03ea..8e4d37c0 100644 --- a/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java +++ b/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java @@ -36,9 +36,6 @@ import java.util.UUID; import java.util.stream.Stream; public final class FileSystemPackerMutationService implements PackerMutationService { - private static final String RECOVERED_DIR = "recovered"; - private static final String QUARANTINE_DIR = "quarantine"; - private final PackerWorkspaceFoundation workspaceFoundation; private final PackerAssetDetailsService detailsService; private final PackerProjectWriteCoordinator writeCoordinator; @@ -97,10 +94,10 @@ public final class FileSystemPackerMutationService implements PackerMutationServ final Path assetRoot = context.assetDetails().summary().identity().assetRoot(); return switch (preview.request().type()) { - case REGISTER_ASSET, ADOPT_ASSET -> applyRegister(project, registry, assetRoot, preview); - case FORGET_ASSET -> applyForget(project, registry, assetRoot, preview); + case REGISTER_ASSET -> applyRegister(project, registry, assetRoot, preview); + case INCLUDE_ASSET_IN_BUILD -> applyBuildParticipationChange(project, registry, assetRoot, preview, true); + case EXCLUDE_ASSET_FROM_BUILD -> applyBuildParticipationChange(project, registry, assetRoot, preview, false); case REMOVE_ASSET -> applyRemove(project, registry, assetRoot, preview); - case QUARANTINE_ASSET -> applyQuarantine(project, registry, assetRoot, preview); case RELOCATE_ASSET -> applyRelocate(project, registry, assetRoot, preview); }; } @@ -131,18 +128,26 @@ public final class FileSystemPackerMutationService implements PackerMutationServ List.of()); } - private PackerMutationResult applyForget( + private PackerMutationResult applyBuildParticipationChange( PackerProjectContext project, PackerRegistryState registry, Path assetRoot, - PackerMutationPreview preview) { + PackerMutationPreview preview, + boolean includedInBuild) { final PackerRegistryState updated = registry.withAssets( registry.assets().stream() - .filter(entry -> !PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot)) + .map(entry -> PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot) + ? new PackerRegistryEntry(entry.assetId(), entry.assetUuid(), entry.root(), includedInBuild) + : entry) .toList(), registry.nextAssetId()); workspaceFoundation.saveRegistry(project, updated); - return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset forgotten.", preview.operationId(), preview.proposedActions(), List.of()); + return new PackerMutationResult( + PackerOperationStatus.SUCCESS, + includedInBuild ? "Asset included in builds." : "Asset excluded from builds.", + preview.operationId(), + preview.proposedActions(), + List.of()); } private PackerMutationResult applyRemove( @@ -160,21 +165,6 @@ public final class FileSystemPackerMutationService implements PackerMutationServ return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset removed from workspace.", preview.operationId(), preview.proposedActions(), List.of()); } - private PackerMutationResult applyQuarantine( - PackerProjectContext project, - PackerRegistryState registry, - Path assetRoot, - PackerMutationPreview preview) { - final PackerRegistryState updated = registry.withAssets( - registry.assets().stream() - .filter(entry -> !PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot)) - .toList(), - registry.nextAssetId()); - workspaceFoundation.saveRegistry(project, updated); - moveAssetRoot(assetRoot, requireTarget(preview)); - return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset moved to quarantine.", preview.operationId(), preview.proposedActions(), List.of()); - } - private PackerMutationResult applyRelocate( PackerProjectContext project, PackerRegistryState registry, @@ -184,7 +174,11 @@ public final class FileSystemPackerMutationService implements PackerMutationServ final PackerRegistryState updated = registry.withAssets( registry.assets().stream() .map(entry -> PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot) - ? new PackerRegistryEntry(entry.assetId(), entry.assetUuid(), PackerWorkspacePaths.relativeAssetRoot(project, targetRoot)) + ? new PackerRegistryEntry( + entry.assetId(), + entry.assetUuid(), + PackerWorkspacePaths.relativeAssetRoot(project, targetRoot), + entry.includedInBuild()) : entry) .toList(), registry.nextAssetId()); @@ -206,11 +200,11 @@ public final class FileSystemPackerMutationService implements PackerMutationServ Path targetAssetRoot = null; switch (request.type()) { - case REGISTER_ASSET, ADOPT_ASSET -> { + case REGISTER_ASSET -> { if (managed) { - blockers.add("Asset is already managed."); + blockers.add("Asset is already registered."); } - if (assetDetails.summary().state() == PackerAssetState.INVALID) { + if (assetDetails.diagnostics().stream().anyMatch(PackerDiagnostic::blocking)) { blockers.add("Asset declaration must be valid before registration."); } if (!blockers.isEmpty()) { @@ -221,12 +215,24 @@ public final class FileSystemPackerMutationService implements PackerMutationServ warnings.add("Asset currently reports diagnostics and will still be registered."); } } - case FORGET_ASSET -> { + case INCLUDE_ASSET_IN_BUILD -> { if (!managed) { - blockers.add("Only managed assets can be forgotten."); + blockers.add("Only registered assets can be included in builds."); + } else if (assetDetails.summary().buildParticipation() == p.packer.api.assets.PackerBuildParticipation.INCLUDED) { + blockers.add("Asset is already included in builds."); } else { - actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "REMOVE", relativeRoot)); - warnings.add("The asset will leave the managed build set."); + actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "UPDATE", relativeRoot)); + warnings.add("The asset will remain registered and return to the build set."); + } + } + case EXCLUDE_ASSET_FROM_BUILD -> { + if (!managed) { + blockers.add("Only registered assets can be excluded from builds."); + } else if (assetDetails.summary().buildParticipation() == p.packer.api.assets.PackerBuildParticipation.EXCLUDED) { + blockers.add("Asset is already excluded from builds."); + } else { + actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "UPDATE", relativeRoot)); + warnings.add("The asset will remain registered but leave the build set."); } } case REMOVE_ASSET -> { @@ -236,24 +242,6 @@ public final class FileSystemPackerMutationService implements PackerMutationServ actions.add(new PackerProposedAction(PackerOperationClass.WORKSPACE_MUTATION, "DELETE", relativeRoot)); warnings.add("Physical files inside the asset root will be deleted."); } - case QUARANTINE_ASSET -> { - if (isInsideQuarantine(context.project(), assetRoot)) { - blockers.add("Asset is already inside quarantine."); - } else { - targetAssetRoot = request.targetRoot() != null - ? request.targetRoot() - : nextAvailablePath(quarantineRoot(context.project()), sanitizeSegment(assetDetails.summary().identity().assetName())); - if (managed) { - actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "REMOVE", relativeRoot)); - warnings.add("Quarantining a managed asset removes it from the active registry."); - } - actions.add(new PackerProposedAction( - PackerOperationClass.WORKSPACE_MUTATION, - "MOVE", - relativeRoot + " -> " + PackerWorkspacePaths.relativeAssetRoot(context.project(), targetAssetRoot))); - warnings.add("Quarantine is explicit and reversible, but the asset will leave its current workspace location."); - } - } case RELOCATE_ASSET -> { targetAssetRoot = request.targetRoot() != null ? request.targetRoot() @@ -291,26 +279,15 @@ public final class FileSystemPackerMutationService implements PackerMutationServ final List initialBlockers = assetDetails.diagnostics().stream() .filter(PackerDiagnostic::blocking) .map(PackerDiagnostic::message) - .filter(message -> request.type() != PackerMutationType.FORGET_ASSET + .filter(message -> request.type() != PackerMutationType.INCLUDE_ASSET_IN_BUILD + && request.type() != PackerMutationType.EXCLUDE_ASSET_FROM_BUILD && request.type() != PackerMutationType.REMOVE_ASSET - && request.type() != PackerMutationType.QUARANTINE_ASSET && request.type() != PackerMutationType.RELOCATE_ASSET) .toList(); return new ResolvedMutationContext(request.project(), request, assetDetails, initialBlockers); } - private Path quarantineRoot(PackerProjectContext project) { - return PackerWorkspacePaths.registryDirectory(project).resolve(QUARANTINE_DIR).toAbsolutePath().normalize(); - } - - private boolean isInsideQuarantine(PackerProjectContext project, Path assetRoot) { - return assetRoot.toAbsolutePath().normalize().startsWith(quarantineRoot(project)); - } - private Path relocationTarget(PackerProjectContext project, Path assetRoot, String assetName) { - if (isInsideQuarantine(project, assetRoot)) { - return nextAvailablePath(PackerWorkspacePaths.assetsRoot(project).resolve(RECOVERED_DIR), sanitizeSegment(assetName)); - } final Path siblingParent = assetRoot.getParent() == null ? PackerWorkspacePaths.assetsRoot(project) : assetRoot.getParent(); return nextAvailablePath(siblingParent, assetRoot.getFileName().toString() + "-relocated"); } diff --git a/prometeu-packer/src/main/java/p/packer/workspace/FileSystemPackerWorkspaceService.java b/prometeu-packer/src/main/java/p/packer/workspace/FileSystemPackerWorkspaceService.java index 4bc9e300..eb3a7f30 100644 --- a/prometeu-packer/src/main/java/p/packer/workspace/FileSystemPackerWorkspaceService.java +++ b/prometeu-packer/src/main/java/p/packer/workspace/FileSystemPackerWorkspaceService.java @@ -6,6 +6,7 @@ import p.packer.api.PackerProjectContext; import p.packer.api.assets.PackerAssetIdentity; import p.packer.api.assets.PackerAssetState; import p.packer.api.assets.PackerAssetSummary; +import p.packer.api.assets.PackerBuildParticipation; import p.packer.api.diagnostics.PackerDiagnostic; import p.packer.api.diagnostics.PackerDiagnosticCategory; import p.packer.api.diagnostics.PackerDiagnosticSeverity; @@ -160,9 +161,12 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe : "unknown"; final boolean preload = parsed.declaration() != null && parsed.declaration().preloadEnabled(); final boolean hasDiagnostics = !parsed.diagnostics().isEmpty(); - final PackerAssetState state = parsed.valid() - ? (registryEntry == null ? PackerAssetState.ORPHAN : PackerAssetState.MANAGED) - : PackerAssetState.INVALID; + final PackerAssetState state = registryEntry == null + ? PackerAssetState.UNREGISTERED + : PackerAssetState.REGISTERED; + final PackerBuildParticipation buildParticipation = state == PackerAssetState.REGISTERED + ? (registryEntry.includedInBuild() ? PackerBuildParticipation.INCLUDED : PackerBuildParticipation.EXCLUDED) + : PackerBuildParticipation.EXCLUDED; return new PackerAssetSummary( new PackerAssetIdentity( registryEntry == null ? null : registryEntry.assetId(), @@ -170,6 +174,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe assetName, assetRoot), state, + buildParticipation, assetFamily, preload, hasDiagnostics); diff --git a/prometeu-packer/src/test/java/p/packer/declarations/PackerAssetDetailsServiceTest.java b/prometeu-packer/src/test/java/p/packer/declarations/PackerAssetDetailsServiceTest.java index 00a724a4..e14cc30e 100644 --- a/prometeu-packer/src/test/java/p/packer/declarations/PackerAssetDetailsServiceTest.java +++ b/prometeu-packer/src/test/java/p/packer/declarations/PackerAssetDetailsServiceTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import p.packer.api.PackerOperationStatus; import p.packer.api.PackerProjectContext; +import p.packer.api.assets.PackerBuildParticipation; import p.packer.api.assets.PackerAssetState; import p.packer.api.workspace.GetAssetDetailsRequest; import p.packer.testing.PackerFixtureLocator; @@ -20,28 +21,30 @@ final class PackerAssetDetailsServiceTest { Path tempDir; @Test - void returnsManagedDetailsForRegisteredAssetReferenceById() throws Exception { + void returnsRegisteredDetailsForRegisteredAssetReferenceById() throws Exception { final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("managed")); final PackerAssetDetailsService service = new PackerAssetDetailsService(); final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), "1")); assertEquals(PackerOperationStatus.SUCCESS, result.status()); - assertEquals(PackerAssetState.MANAGED, result.details().summary().state()); + assertEquals(PackerAssetState.REGISTERED, result.details().summary().state()); + assertEquals(PackerBuildParticipation.INCLUDED, result.details().summary().buildParticipation()); assertEquals("ui_atlas", result.details().summary().identity().assetName()); assertEquals("TILES/indexed_v1", result.details().outputFormat()); assertTrue(result.diagnostics().isEmpty()); } @Test - void returnsOrphanDetailsForValidUnregisteredRootReference() throws Exception { + void returnsUnregisteredDetailsForValidUnregisteredRootReference() throws Exception { final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("orphan")); final PackerAssetDetailsService service = new PackerAssetDetailsService(); final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), "orphans/ui_sounds")); assertEquals(PackerOperationStatus.SUCCESS, result.status()); - assertEquals(PackerAssetState.ORPHAN, result.details().summary().state()); + assertEquals(PackerAssetState.UNREGISTERED, result.details().summary().state()); + assertEquals(PackerBuildParticipation.EXCLUDED, result.details().summary().buildParticipation()); assertEquals("ui_sounds", result.details().summary().identity().assetName()); } @@ -53,7 +56,8 @@ final class PackerAssetDetailsServiceTest { final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), "bad")); assertEquals(PackerOperationStatus.FAILED, result.status()); - assertEquals(PackerAssetState.INVALID, result.details().summary().state()); + assertEquals(PackerAssetState.UNREGISTERED, result.details().summary().state()); + assertEquals(PackerBuildParticipation.EXCLUDED, result.details().summary().buildParticipation()); assertFalse(result.diagnostics().isEmpty()); } @@ -64,7 +68,8 @@ final class PackerAssetDetailsServiceTest { final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(tempDir.resolve("empty")), "missing/root")); assertEquals(PackerOperationStatus.FAILED, result.status()); - assertEquals(PackerAssetState.INVALID, result.details().summary().state()); + assertEquals(PackerAssetState.UNREGISTERED, result.details().summary().state()); + assertEquals(PackerBuildParticipation.EXCLUDED, result.details().summary().buildParticipation()); assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("could not be resolved"))); } diff --git a/prometeu-packer/src/test/java/p/packer/doctor/FileSystemPackerDoctorServiceTest.java b/prometeu-packer/src/test/java/p/packer/doctor/FileSystemPackerDoctorServiceTest.java index f4b57cac..97dd2a13 100644 --- a/prometeu-packer/src/test/java/p/packer/doctor/FileSystemPackerDoctorServiceTest.java +++ b/prometeu-packer/src/test/java/p/packer/doctor/FileSystemPackerDoctorServiceTest.java @@ -40,7 +40,7 @@ final class FileSystemPackerDoctorServiceTest { } @Test - void expandedWorkspaceReportsOrphanAssetsAndRegisterSafeFixes() throws Exception { + void expandedWorkspaceReportsUnregisteredAssetsAndRegisterSafeFixes() throws Exception { final Path projectRoot = createExpandedWorkspace(); final FileSystemPackerDoctorService service = new FileSystemPackerDoctorService(); @@ -52,7 +52,7 @@ final class FileSystemPackerDoctorServiceTest { assertEquals(PackerOperationStatus.PARTIAL, result.status()); assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.category() == PackerDiagnosticCategory.HYGIENE - && diagnostic.message().contains("Orphan asset is valid but not registered"))); + && diagnostic.message().contains("not registered, so it stays excluded from builds"))); assertTrue(result.safeFixes().contains("register_asset orphans/ui_sounds")); } diff --git a/prometeu-packer/src/test/java/p/packer/mutations/FileSystemPackerMutationServiceTest.java b/prometeu-packer/src/test/java/p/packer/mutations/FileSystemPackerMutationServiceTest.java index b00fb5b7..2cbf2dfb 100644 --- a/prometeu-packer/src/test/java/p/packer/mutations/FileSystemPackerMutationServiceTest.java +++ b/prometeu-packer/src/test/java/p/packer/mutations/FileSystemPackerMutationServiceTest.java @@ -25,37 +25,6 @@ final class FileSystemPackerMutationServiceTest { @TempDir Path tempDir; - @Test - void previewAndApplyQuarantineForManagedAssetShowsStructuredImpact() throws Exception { - final Path projectRoot = createManagedAssetProject(); - final List events = new CopyOnWriteArrayList<>(); - final FileSystemPackerMutationService service = service(events); - final PackerProjectContext project = project(projectRoot); - - final PackerMutationPreview preview = service.preview(new PackerMutationRequest( - project, - PackerMutationType.QUARANTINE_ASSET, - "1", - null)); - - assertTrue(preview.canApply()); - assertFalse(preview.highRisk()); - assertNotNull(preview.targetAssetRoot()); - assertEquals(1, preview.proposedActions().stream().filter(action -> action.operationClass() == p.packer.api.PackerOperationClass.REGISTRY_MUTATION).count()); - assertEquals(1, preview.proposedActions().stream().filter(action -> action.operationClass() == p.packer.api.PackerOperationClass.WORKSPACE_MUTATION).count()); - - service.apply(preview); - - assertFalse(Files.exists(projectRoot.resolve("assets/ui/atlas"))); - assertTrue(Files.isDirectory(preview.targetAssetRoot())); - final String registryJson = Files.readString(projectRoot.resolve("assets/.prometeu/index.json")); - assertFalse(registryJson.contains("\"root\" : \"ui/atlas\"")); - assertEquals(List.of(PackerEventKind.PREVIEW_READY, PackerEventKind.ASSET_CHANGED, PackerEventKind.ACTION_APPLIED), events.stream().map(PackerEvent::kind).toList()); - assertEquals(preview.operationId(), events.getFirst().operationId()); - assertEquals(preview.operationId(), events.get(1).operationId()); - assertEquals(preview.operationId(), events.get(2).operationId()); - } - @Test void applyRelocatePreservesIdentityAndUpdatesRegistryRoot() throws Exception { final Path projectRoot = createManagedAssetProject(); @@ -104,6 +73,27 @@ final class FileSystemPackerMutationServiceTest { assertTrue(preview.blockers().stream().anyMatch(message -> message.contains("valid before registration"))); } + @Test + void applyExcludeFromBuildPreservesIdentityAndMarksRegistryEntryExcluded() throws Exception { + final Path projectRoot = createManagedAssetProject(); + final FileSystemPackerMutationService service = service(new CopyOnWriteArrayList<>()); + final PackerProjectContext project = project(projectRoot); + + final PackerMutationPreview preview = service.preview(new PackerMutationRequest( + project, + PackerMutationType.EXCLUDE_ASSET_FROM_BUILD, + "1", + null)); + + assertTrue(preview.canApply()); + service.apply(preview); + + final String registryJson = Files.readString(projectRoot.resolve("assets/.prometeu/index.json")); + assertTrue(registryJson.contains("\"asset_id\" : 1")); + assertTrue(registryJson.contains("\"included_in_build\" : false")); + assertTrue(Files.isDirectory(projectRoot.resolve("assets/ui/atlas"))); + } + @Test void emitsFailureLifecycleWhenApplyFails() throws Exception { final Path projectRoot = createManagedAssetProject(); @@ -151,7 +141,8 @@ final class FileSystemPackerMutationServiceTest { { "asset_id": 1, "asset_uuid": "uuid-1", - "root": "ui/atlas" + "root": "ui/atlas", + "included_in_build": true } ] } diff --git a/prometeu-packer/src/test/java/p/packer/workspace/FileSystemPackerWorkspaceServiceTest.java b/prometeu-packer/src/test/java/p/packer/workspace/FileSystemPackerWorkspaceServiceTest.java index 7ebe65a1..e3b11dd9 100644 --- a/prometeu-packer/src/test/java/p/packer/workspace/FileSystemPackerWorkspaceServiceTest.java +++ b/prometeu-packer/src/test/java/p/packer/workspace/FileSystemPackerWorkspaceServiceTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import p.packer.api.PackerOperationStatus; import p.packer.api.PackerProjectContext; +import p.packer.api.assets.PackerBuildParticipation; import p.packer.api.assets.PackerAssetState; import p.packer.api.events.PackerEvent; import p.packer.api.events.PackerEventKind; @@ -23,7 +24,7 @@ final class FileSystemPackerWorkspaceServiceTest { Path tempDir; @Test - void listsManagedAndOrphanAssetsFromWorkspaceScan() throws Exception { + void listsRegisteredAndUnregisteredAssetsFromWorkspaceScan() throws Exception { final Path projectRoot = copyFixture("workspaces/read-mixed", tempDir.resolve("mixed")); final FileSystemPackerWorkspaceService service = new FileSystemPackerWorkspaceService(); @@ -31,8 +32,8 @@ final class FileSystemPackerWorkspaceServiceTest { assertEquals(PackerOperationStatus.SUCCESS, result.status()); assertEquals(2, result.assets().size()); - assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.MANAGED)); - assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.ORPHAN)); + assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.REGISTERED)); + assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.UNREGISTERED)); } @Test @@ -54,7 +55,8 @@ final class FileSystemPackerWorkspaceServiceTest { final var result = service.listAssets(new ListAssetsRequest(project(projectRoot))); assertEquals(1, result.assets().size()); - assertEquals(PackerAssetState.INVALID, result.assets().getFirst().state()); + assertEquals(PackerAssetState.UNREGISTERED, result.assets().getFirst().state()); + assertEquals(PackerBuildParticipation.EXCLUDED, result.assets().getFirst().buildParticipation()); assertTrue(result.assets().getFirst().hasDiagnostics()); } diff --git a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioWorkspaceRailControl.java b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioWorkspaceRailControl.java index f7797953..b62e033a 100644 --- a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioWorkspaceRailControl.java +++ b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioWorkspaceRailControl.java @@ -36,7 +36,13 @@ public final class StudioWorkspaceRailControl extends VBox implements StudioC button.setPrefSize(44, 44); button.setMinSize(44, 44); button.setMaxSize(44, 44); - button.getStyleClass().add("studio-workspace-rail-button"); + button.getStyleClass().addAll( + "studio-button", + "studio-button-ghost", + "studio-button-pill", + "studio-button-icon", + "studio-button-toggle", + "studio-workspace-rail-button"); button.setTooltip(new Tooltip()); button.getTooltip().textProperty().bind(item.label()); button.setOnAction(ignored -> Objects.requireNonNull(onSelect, "onSelect").accept(item.id())); diff --git a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java index 78c12b6b..c2c09feb 100644 --- a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java +++ b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java @@ -79,10 +79,13 @@ public enum I18n { WORKSPACE_ASSETS("workspace.assets"), ASSETS_NAVIGATOR_TITLE("assets.navigator.title"), + ASSETS_NAVIGATOR_ACTION_ADD("assets.navigator.action.add"), + ASSETS_NAVIGATOR_ACTION_DOCTOR("assets.navigator.action.doctor"), + ASSETS_NAVIGATOR_ACTION_PACK("assets.navigator.action.pack"), ASSETS_DETAILS_TITLE("assets.details.title"), ASSETS_SEARCH_PROMPT("assets.search.prompt"), - ASSETS_FILTER_MANAGED("assets.filter.managed"), - ASSETS_FILTER_ORPHAN("assets.filter.orphan"), + ASSETS_FILTER_REGISTERED("assets.filter.registered"), + ASSETS_FILTER_UNREGISTERED("assets.filter.unregistered"), ASSETS_FILTER_DIAGNOSTICS("assets.filter.diagnostics"), ASSETS_FILTER_PRELOAD("assets.filter.preload"), ASSETS_STATE_LOADING("assets.state.loading"), @@ -90,8 +93,8 @@ public enum I18n { ASSETS_STATE_NO_RESULTS("assets.state.noResults"), ASSETS_STATE_READY("assets.state.ready"), ASSETS_STATE_ERROR("assets.state.error"), - ASSETS_BADGE_MANAGED("assets.badge.managed"), - ASSETS_BADGE_ORPHAN("assets.badge.orphan"), + ASSETS_BADGE_REGISTERED("assets.badge.registered"), + ASSETS_BADGE_UNREGISTERED("assets.badge.unregistered"), ASSETS_BADGE_PRELOAD("assets.badge.preload"), ASSETS_BADGE_DIAGNOSTICS("assets.badge.diagnostics"), ASSETS_SECTION_SUMMARY("assets.section.summary"), @@ -99,15 +102,10 @@ public enum I18n { ASSETS_SECTION_INPUTS_PREVIEW("assets.section.inputsPreview"), ASSETS_SECTION_DIAGNOSTICS("assets.section.diagnostics"), ASSETS_SECTION_ACTIONS("assets.section.actions"), - ASSETS_ACTIONS_PRIMARY("assets.actions.primary"), - ASSETS_ACTIONS_SENSITIVE("assets.actions.sensitive"), - ASSETS_ACTION_DOCTOR("assets.action.doctor"), - ASSETS_ACTION_BUILD("assets.action.build"), - ASSETS_ACTION_ADOPT("assets.action.adopt"), ASSETS_ACTION_REGISTER("assets.action.register"), - ASSETS_ACTION_QUARANTINE("assets.action.quarantine"), + ASSETS_ACTION_INCLUDE_IN_BUILD("assets.action.includeInBuild"), + ASSETS_ACTION_EXCLUDE_FROM_BUILD("assets.action.excludeFromBuild"), ASSETS_ACTION_RELOCATE("assets.action.relocate"), - ASSETS_ACTION_FORGET("assets.action.forget"), ASSETS_ACTION_REMOVE("assets.action.remove"), ASSETS_MUTATION_PREVIEW_TITLE("assets.mutation.previewTitle"), ASSETS_MUTATION_SECTION_CHANGES("assets.mutation.section.changes"), @@ -127,15 +125,20 @@ public enum I18n { ASSETS_MUTATION_APPLY("assets.mutation.apply"), ASSETS_MUTATION_CONFIRM_TITLE("assets.mutation.confirm.title"), ASSETS_MUTATION_CONFIRM_HEADER("assets.mutation.confirm.header"), - ASSETS_MUTATION_CONFIRM_BODY("assets.mutation.confirm.body"), ASSETS_LABEL_NAME("assets.label.name"), - ASSETS_LABEL_STATE("assets.label.state"), + ASSETS_LABEL_REGISTRATION("assets.label.registration"), + ASSETS_LABEL_BUILD_PARTICIPATION("assets.label.buildParticipation"), ASSETS_LABEL_ASSET_ID("assets.label.assetId"), ASSETS_LABEL_TYPE("assets.label.type"), ASSETS_LABEL_LOCATION("assets.label.location"), + ASSETS_LABEL_TARGET_LOCATION("assets.label.targetLocation"), ASSETS_LABEL_FORMAT("assets.label.format"), ASSETS_LABEL_CODEC("assets.label.codec"), ASSETS_LABEL_PRELOAD("assets.label.preload"), + ASSETS_VALUE_REGISTERED("assets.value.registered"), + ASSETS_VALUE_UNREGISTERED("assets.value.unregistered"), + ASSETS_VALUE_INCLUDED("assets.value.included"), + ASSETS_VALUE_EXCLUDED("assets.value.excluded"), ASSETS_VALUE_YES("assets.value.yes"), ASSETS_VALUE_NO("assets.value.no"), ASSETS_PROGRESS_IDLE("assets.progress.idle"), @@ -160,6 +163,49 @@ public enum I18n { ASSETS_DETAILS_EMPTY("assets.details.empty"), ASSETS_DETAILS_READY("assets.details.ready"), ASSETS_DETAILS_NO_SELECTION("assets.details.noSelection"), + ASSETS_ADD_WIZARD_TITLE("assets.addWizard.title"), + ASSETS_ADD_WIZARD_DESCRIPTION("assets.addWizard.description"), + ASSETS_ADD_WIZARD_STEP_ROOT_TITLE("assets.addWizard.step.root.title"), + ASSETS_ADD_WIZARD_STEP_ROOT_DESCRIPTION("assets.addWizard.step.root.description"), + ASSETS_ADD_WIZARD_STEP_DETAILS_TITLE("assets.addWizard.step.details.title"), + ASSETS_ADD_WIZARD_STEP_DETAILS_DESCRIPTION("assets.addWizard.step.details.description"), + ASSETS_ADD_WIZARD_STEP_SUMMARY_TITLE("assets.addWizard.step.summary.title"), + ASSETS_ADD_WIZARD_STEP_SUMMARY_DESCRIPTION("assets.addWizard.step.summary.description"), + ASSETS_ADD_WIZARD_LABEL_NAME("assets.addWizard.label.name"), + ASSETS_ADD_WIZARD_LABEL_ROOT("assets.addWizard.label.root"), + ASSETS_ADD_WIZARD_LABEL_TYPE("assets.addWizard.label.type"), + ASSETS_ADD_WIZARD_LABEL_FORMAT("assets.addWizard.label.format"), + ASSETS_ADD_WIZARD_LABEL_CODEC("assets.addWizard.label.codec"), + ASSETS_ADD_WIZARD_LABEL_PRELOAD("assets.addWizard.label.preload"), + ASSETS_ADD_WIZARD_PROMPT_TYPE("assets.addWizard.prompt.type"), + ASSETS_ADD_WIZARD_PROMPT_FORMAT("assets.addWizard.prompt.format"), + ASSETS_ADD_WIZARD_PROMPT_CODEC("assets.addWizard.prompt.codec"), + ASSETS_ADD_WIZARD_ASSETS_ROOT_HINT("assets.addWizard.assetsRootHint"), + ASSETS_ADD_WIZARD_BROWSE_TITLE("assets.addWizard.browse.title"), + ASSETS_ADD_WIZARD_NOTE("assets.addWizard.note"), + ASSETS_ADD_WIZARD_BUTTON_CREATE("assets.addWizard.button.create"), + ASSETS_ADD_WIZARD_ERROR_NAME("assets.addWizard.error.name"), + ASSETS_ADD_WIZARD_ERROR_ROOT("assets.addWizard.error.root"), + ASSETS_ADD_WIZARD_ERROR_TYPE("assets.addWizard.error.type"), + ASSETS_ADD_WIZARD_ERROR_FORMAT("assets.addWizard.error.format"), + ASSETS_ADD_WIZARD_ERROR_CODEC("assets.addWizard.error.codec"), + ASSETS_ADD_WIZARD_ERROR_UNSUPPORTED_COMBINATION("assets.addWizard.error.unsupportedCombination"), + ASSETS_RELOCATE_WIZARD_TITLE("assets.relocateWizard.title"), + ASSETS_RELOCATE_WIZARD_DESCRIPTION("assets.relocateWizard.description"), + ASSETS_RELOCATE_WIZARD_STEP_DESTINATION_TITLE("assets.relocateWizard.step.destination.title"), + ASSETS_RELOCATE_WIZARD_STEP_DESTINATION_DESCRIPTION("assets.relocateWizard.step.destination.description"), + ASSETS_RELOCATE_WIZARD_STEP_SUMMARY_TITLE("assets.relocateWizard.step.summary.title"), + ASSETS_RELOCATE_WIZARD_STEP_SUMMARY_DESCRIPTION("assets.relocateWizard.step.summary.description"), + ASSETS_RELOCATE_WIZARD_LABEL_CURRENT_ROOT("assets.relocateWizard.label.currentRoot"), + ASSETS_RELOCATE_WIZARD_LABEL_DESTINATION_PARENT("assets.relocateWizard.label.destinationParent"), + ASSETS_RELOCATE_WIZARD_LABEL_DESTINATION_NAME("assets.relocateWizard.label.destinationName"), + ASSETS_RELOCATE_WIZARD_LABEL_TARGET_ROOT("assets.relocateWizard.label.targetRoot"), + ASSETS_RELOCATE_WIZARD_PROMPT_DESTINATION_NAME("assets.relocateWizard.prompt.destinationName"), + ASSETS_RELOCATE_WIZARD_ASSETS_ROOT_HINT("assets.relocateWizard.assetsRootHint"), + ASSETS_RELOCATE_WIZARD_BROWSE_TITLE("assets.relocateWizard.browse.title"), + ASSETS_RELOCATE_WIZARD_NOTE("assets.relocateWizard.note"), + ASSETS_RELOCATE_WIZARD_BUTTON_CONFIRM("assets.relocateWizard.button.confirm"), + ASSETS_RELOCATE_WIZARD_BUTTON_PREVIEW("assets.relocateWizard.button.preview"), WORKSPACE_DEBUG("workspace.debug"), diff --git a/prometeu-studio/src/main/java/p/studio/window/NewProjectWizard.java b/prometeu-studio/src/main/java/p/studio/window/NewProjectWizard.java index 9dea6b68..0f62c10e 100644 --- a/prometeu-studio/src/main/java/p/studio/window/NewProjectWizard.java +++ b/prometeu-studio/src/main/java/p/studio/window/NewProjectWizard.java @@ -81,16 +81,20 @@ public final class NewProjectWizard { feedbackLabel.setWrapText(true); backButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BACK)); + backButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); backButton.setOnAction(ignored -> goBack()); nextButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_NEXT)); + nextButton.getStyleClass().addAll("studio-button", "studio-button-primary"); nextButton.setOnAction(ignored -> goNext()); createButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_CREATE)); + createButton.getStyleClass().addAll("studio-button", "studio-button-primary"); createButton.setOnAction(ignored -> finishCreate()); final Button cancelButton = new Button(); cancelButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_CANCEL)); + cancelButton.getStyleClass().addAll("studio-button", "studio-button-cancel"); cancelButton.setOnAction(ignored -> stage.close()); final HBox actions = new HBox(12, backButton, nextButton, createButton, cancelButton); @@ -155,6 +159,7 @@ public final class NewProjectWizard { final Button browseButton = new Button(); browseButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BROWSE)); + browseButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); browseButton.setOnAction(ignored -> browseForLocation()); final HBox row = new HBox(12, locationField, browseButton); diff --git a/prometeu-studio/src/main/java/p/studio/window/ProjectLauncherView.java b/prometeu-studio/src/main/java/p/studio/window/ProjectLauncherView.java index cc8f1929..57bb0c3e 100644 --- a/prometeu-studio/src/main/java/p/studio/window/ProjectLauncherView.java +++ b/prometeu-studio/src/main/java/p/studio/window/ProjectLauncherView.java @@ -88,15 +88,18 @@ public final class ProjectLauncherView extends BorderPane { final Button openButton = new Button(); openButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_OPEN_PROJECT)); + openButton.getStyleClass().addAll("studio-button", "studio-button-primary"); openButton.disableProperty().bind(projectList.getSelectionModel().selectedItemProperty().isNull()); openButton.setOnAction(ignored -> openSelectedProject()); final Button addButton = new Button(); addButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_ADD_PROJECT)); + addButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); addButton.setOnAction(ignored -> addExistingProject()); final Button forgetButton = new Button(); forgetButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_FORGET_PROJECT)); + forgetButton.getStyleClass().addAll("studio-button", "studio-button-warning"); forgetButton.disableProperty().bind(projectList.getSelectionModel().selectedItemProperty().isNull()); forgetButton.setOnAction(ignored -> forgetSelectedProject()); @@ -109,6 +112,7 @@ public final class ProjectLauncherView extends BorderPane { final Button createButton = new Button(); createButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_CREATE_BUTTON)); + createButton.getStyleClass().addAll("studio-button", "studio-button-primary"); createButton.setOnAction(ignored -> openCreateWizard()); final HBox createRow = new HBox(10, createButton); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationCatalog.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationCatalog.java new file mode 100644 index 00000000..59a780ec --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationCatalog.java @@ -0,0 +1,67 @@ +package p.studio.workspaces.assets; + +import java.util.List; +import java.util.Objects; + +public final class AssetCreationCatalog { + private static final List TYPES = List.of( + new AssetTypeOption( + "image_bank", + List.of( + new AssetFormatOption("TILES/indexed_v1", List.of("RAW")), + new AssetFormatOption("SPRITES/indexed_v1", List.of("RAW")))), + new AssetTypeOption( + "sound_bank", + List.of(new AssetFormatOption("AUDIO/pcm_v1", List.of("RAW")))), + new AssetTypeOption( + "palette_bank", + List.of(new AssetFormatOption("PALETTE/indexed_v1", List.of("RAW"))))); + + private AssetCreationCatalog() { + } + + public static List assetTypes() { + return TYPES.stream().map(AssetTypeOption::assetType).toList(); + } + + public static List outputFormatsFor(String assetType) { + return findType(assetType).formats().stream().map(AssetFormatOption::outputFormat).toList(); + } + + public static List outputCodecsFor(String assetType, String outputFormat) { + final AssetTypeOption type = findType(assetType); + return type.formats().stream() + .filter(format -> format.outputFormat().equals(outputFormat)) + .findFirst() + .map(AssetFormatOption::codecs) + .orElse(List.of()); + } + + public static boolean supports(String assetType, String outputFormat, String outputCodec) { + return outputCodecsFor(assetType, outputFormat).contains(outputCodec); + } + + private static AssetTypeOption findType(String assetType) { + final String normalized = Objects.requireNonNullElse(assetType, "").trim(); + return TYPES.stream() + .filter(type -> type.assetType().equals(normalized)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported asset type: " + assetType)); + } + + record AssetTypeOption( + String assetType, + List formats) { + AssetTypeOption { + formats = List.copyOf(formats); + } + } + + record AssetFormatOption( + String outputFormat, + List codecs) { + AssetFormatOption { + codecs = List.copyOf(codecs); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationRequest.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationRequest.java new file mode 100644 index 00000000..2914b7b0 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationRequest.java @@ -0,0 +1,27 @@ +package p.studio.workspaces.assets; + +import java.util.Objects; + +public record AssetCreationRequest( + String assetName, + String relativeRoot, + String assetType, + String outputFormat, + String outputCodec, + boolean preloadEnabled) { + + public AssetCreationRequest { + assetName = Objects.requireNonNull(assetName, "assetName").trim(); + relativeRoot = Objects.requireNonNull(relativeRoot, "relativeRoot").trim(); + assetType = Objects.requireNonNull(assetType, "assetType").trim(); + outputFormat = Objects.requireNonNull(outputFormat, "outputFormat").trim(); + outputCodec = Objects.requireNonNull(outputCodec, "outputCodec").trim(); + if (assetName.isBlank() + || relativeRoot.isBlank() + || assetType.isBlank() + || outputFormat.isBlank() + || outputCodec.isBlank()) { + throw new IllegalArgumentException("Asset creation fields must not be blank."); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationResult.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationResult.java new file mode 100644 index 00000000..a35bcf36 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationResult.java @@ -0,0 +1,14 @@ +package p.studio.workspaces.assets; + +import java.nio.file.Path; +import java.util.Objects; + +public record AssetCreationResult( + AssetWorkspaceSelectionKey selectionKey, + Path assetRoot) { + + public AssetCreationResult { + selectionKey = Objects.requireNonNull(selectionKey, "selectionKey"); + assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationService.java new file mode 100644 index 00000000..9cde0753 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationService.java @@ -0,0 +1,7 @@ +package p.studio.workspaces.assets; + +import p.studio.projects.ProjectReference; + +public interface AssetCreationService { + AssetCreationResult create(ProjectReference projectReference, AssetCreationRequest request); +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationWizard.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationWizard.java new file mode 100644 index 00000000..d9fd2251 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationWizard.java @@ -0,0 +1,354 @@ +package p.studio.workspaces.assets; + +import javafx.collections.FXCollections; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.stage.DirectoryChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; +import javafx.stage.Window; +import p.studio.Container; +import p.studio.projects.ProjectReference; +import p.studio.utilities.i18n.I18n; + +import java.io.File; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +public final class AssetCreationWizard { + private final AssetCreationService assetCreationService; + private final ProjectReference projectReference; + private final Stage stage; + private final AtomicReference result = new AtomicReference<>(); + + private final Label stepTitle = new Label(); + private final Label stepDescription = new Label(); + private final VBox stepBody = new VBox(12); + private final Label feedbackLabel = new Label(); + + private final TextField rootField = new TextField(); + private final TextField nameField = new TextField(); + private final ComboBox typeCombo = new ComboBox<>(); + private final ComboBox formatCombo = new ComboBox<>(); + private final ComboBox codecCombo = new ComboBox<>(); + private final CheckBox preloadField = new CheckBox(); + + private final Button backButton = new Button(); + private final Button nextButton = new Button(); + private final Button createButton = new Button(); + private final Button cancelButton = new Button(); + + private int stepIndex = 0; + + private AssetCreationWizard( + Window owner, + ProjectReference projectReference, + AssetCreationService assetCreationService) { + this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); + this.assetCreationService = Objects.requireNonNull(assetCreationService, "assetCreationService"); + this.stage = new Stage(); + stage.initOwner(owner); + stage.initModality(Modality.WINDOW_MODAL); + stage.setTitle(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_TITLE)); + stage.setScene(new Scene(buildRoot(), 620, 460)); + stage.getScene().getStylesheets().add(Container.theme().getDefaultTheme()); + + configureControls(); + renderStep(); + } + + public static Optional showAndWait( + Window owner, + ProjectReference projectReference, + AssetCreationService assetCreationService) { + final AssetCreationWizard wizard = new AssetCreationWizard(owner, projectReference, assetCreationService); + wizard.stage.showAndWait(); + return Optional.ofNullable(wizard.result.get()); + } + + private VBox buildRoot() { + stepTitle.getStyleClass().add("studio-launcher-section-title"); + stepDescription.getStyleClass().add("studio-launcher-subtitle"); + stepDescription.setWrapText(true); + feedbackLabel.getStyleClass().add("studio-launcher-feedback"); + feedbackLabel.setWrapText(true); + + backButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BACK)); + backButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); + backButton.setOnAction(ignored -> goBack()); + + nextButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_NEXT)); + nextButton.getStyleClass().addAll("studio-button", "studio-button-primary"); + nextButton.setOnAction(ignored -> goNext()); + + createButton.textProperty().bind(Container.i18n().bind(I18n.ASSETS_ADD_WIZARD_BUTTON_CREATE)); + createButton.getStyleClass().addAll("studio-button", "studio-button-primary"); + createButton.setOnAction(ignored -> createAsset()); + + cancelButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_CANCEL)); + cancelButton.getStyleClass().addAll("studio-button", "studio-button-cancel"); + cancelButton.setOnAction(ignored -> stage.close()); + + final Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + final HBox actions = new HBox(12, backButton, spacer, cancelButton, nextButton, createButton); + actions.setAlignment(Pos.CENTER_RIGHT); + + final VBox root = new VBox(16, stepTitle, stepDescription, stepBody, feedbackLabel, actions); + root.setPadding(new Insets(24)); + VBox.setVgrow(stepBody, Priority.ALWAYS); + return root; + } + + private void configureControls() { + rootField.setPromptText("ui/new_asset"); + nameField.setPromptText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_NAME)); + typeCombo.setPromptText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_PROMPT_TYPE)); + formatCombo.setPromptText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_PROMPT_FORMAT)); + codecCombo.setPromptText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_PROMPT_CODEC)); + typeCombo.setMaxWidth(Double.MAX_VALUE); + formatCombo.setMaxWidth(Double.MAX_VALUE); + codecCombo.setMaxWidth(Double.MAX_VALUE); + typeCombo.setItems(FXCollections.observableArrayList(AssetCreationCatalog.assetTypes())); + formatCombo.setDisable(true); + codecCombo.setDisable(true); + preloadField.setSelected(true); + preloadField.textProperty().bind(Container.i18n().bind(I18n.ASSETS_ADD_WIZARD_LABEL_PRELOAD)); + + typeCombo.valueProperty().addListener((ignored, oldValue, newValue) -> { + if (Objects.equals(oldValue, newValue)) { + return; + } + formatCombo.setItems(FXCollections.observableArrayList()); + formatCombo.getSelectionModel().clearSelection(); + codecCombo.setItems(FXCollections.observableArrayList()); + codecCombo.getSelectionModel().clearSelection(); + codecCombo.setDisable(true); + if (newValue == null || newValue.isBlank()) { + formatCombo.setDisable(true); + return; + } + formatCombo.setItems(FXCollections.observableArrayList(AssetCreationCatalog.outputFormatsFor(newValue))); + formatCombo.setDisable(false); + }); + + formatCombo.valueProperty().addListener((ignored, oldValue, newValue) -> { + if (Objects.equals(oldValue, newValue)) { + return; + } + codecCombo.setItems(FXCollections.observableArrayList()); + codecCombo.getSelectionModel().clearSelection(); + if (newValue == null || newValue.isBlank() || typeCombo.getValue() == null || typeCombo.getValue().isBlank()) { + codecCombo.setDisable(true); + return; + } + codecCombo.setItems(FXCollections.observableArrayList(AssetCreationCatalog.outputCodecsFor(typeCombo.getValue(), newValue))); + codecCombo.setDisable(false); + }); + } + + private void renderStep() { + feedbackLabel.setText(""); + backButton.setDisable(stepIndex == 0); + nextButton.setVisible(stepIndex < 2); + nextButton.setManaged(stepIndex < 2); + createButton.setVisible(stepIndex == 2); + createButton.setManaged(stepIndex == 2); + + switch (stepIndex) { + case 0 -> renderRootStep(); + case 1 -> renderMetadataStep(); + case 2 -> renderSummaryStep(); + default -> throw new IllegalStateException("Unknown asset creation step: " + stepIndex); + } + } + + private void renderRootStep() { + stepTitle.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_STEP_ROOT_TITLE)); + stepDescription.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_STEP_ROOT_DESCRIPTION)); + + final Label assetsRootLabel = new Label(Container.i18n().format( + I18n.ASSETS_ADD_WIZARD_ASSETS_ROOT_HINT, + AssetRootValidator.assetsRoot(projectReference).toString())); + assetsRootLabel.getStyleClass().add("studio-launcher-subtitle"); + assetsRootLabel.setWrapText(true); + + final Button browseButton = new Button(Container.i18n().text(I18n.WIZARD_BROWSE)); + browseButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); + browseButton.setOnAction(ignored -> browseForRoot()); + + final HBox row = new HBox(12, rootField, browseButton); + row.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(rootField, Priority.ALWAYS); + + stepBody.getChildren().setAll( + field(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_ROOT), row), + assetsRootLabel); + } + + private void renderMetadataStep() { + stepTitle.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_STEP_DETAILS_TITLE)); + stepDescription.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_STEP_DETAILS_DESCRIPTION)); + + stepBody.getChildren().setAll( + field(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_NAME), nameField), + field(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_TYPE), typeCombo), + field(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_FORMAT), formatCombo), + field(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_CODEC), codecCombo)); + } + + private void renderSummaryStep() { + stepTitle.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_STEP_SUMMARY_TITLE)); + stepDescription.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_STEP_SUMMARY_DESCRIPTION)); + + final AssetRootValidationResult rootValidation = AssetRootValidator.validate(projectReference, rootField.getText().trim()); + final VBox summary = new VBox(8); + summary.getChildren().addAll( + summaryRow(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_ROOT), rootValidation.normalizedRelativeRoot()), + summaryRow(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_NAME), nameField.getText().trim()), + summaryRow(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_TYPE), Objects.requireNonNullElse(typeCombo.getValue(), "—")), + summaryRow(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_FORMAT), Objects.requireNonNullElse(formatCombo.getValue(), "—")), + summaryRow(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_CODEC), Objects.requireNonNullElse(codecCombo.getValue(), "—"))); + + final Label note = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_NOTE)); + note.getStyleClass().add("studio-launcher-subtitle"); + note.setWrapText(true); + + stepBody.getChildren().setAll(summary, preloadField, note); + } + + private VBox field(String labelText, Node control) { + final Label label = new Label(labelText); + final VBox box = new VBox(6, label, control); + VBox.setVgrow(control, Priority.NEVER); + return box; + } + + private Node summaryRow(String key, String value) { + final HBox row = new HBox(12); + final Label keyLabel = new Label(key); + keyLabel.getStyleClass().add("assets-details-key"); + final Label valueLabel = new Label(value == null || value.isBlank() ? "—" : value); + valueLabel.getStyleClass().add("assets-details-value"); + valueLabel.setWrapText(true); + HBox.setHgrow(valueLabel, Priority.ALWAYS); + row.getChildren().addAll(keyLabel, valueLabel); + return row; + } + + private void goBack() { + if (stepIndex == 0) { + return; + } + stepIndex -= 1; + renderStep(); + } + + private void goNext() { + if (!validateCurrentStep()) { + return; + } + stepIndex += 1; + renderStep(); + } + + private boolean validateCurrentStep() { + return switch (stepIndex) { + case 0 -> validateRootStep(); + case 1 -> validateMetadataStep(); + default -> true; + }; + } + + private boolean validateRootStep() { + final AssetRootValidationResult validation = AssetRootValidator.validate(projectReference, rootField.getText().trim()); + if (!validation.valid()) { + feedbackLabel.setText(validation.message().isBlank() + ? Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_ROOT) + : validation.message()); + return false; + } + rootField.setText(validation.normalizedRelativeRoot()); + return true; + } + + private boolean validateMetadataStep() { + if (nameField.getText().trim().isBlank()) { + feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_NAME)); + return false; + } + if (typeCombo.getValue() == null || typeCombo.getValue().isBlank()) { + feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_TYPE)); + return false; + } + if (formatCombo.getValue() == null || formatCombo.getValue().isBlank()) { + feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_FORMAT)); + return false; + } + if (codecCombo.getValue() == null || codecCombo.getValue().isBlank()) { + feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_CODEC)); + return false; + } + if (!AssetCreationCatalog.supports(typeCombo.getValue(), formatCombo.getValue(), codecCombo.getValue())) { + feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_UNSUPPORTED_COMBINATION)); + return false; + } + return true; + } + + private void createAsset() { + feedbackLabel.setText(""); + if (!validateRootStep() || !validateMetadataStep()) { + return; + } + + try { + result.set(assetCreationService.create(projectReference, new AssetCreationRequest( + nameField.getText().trim(), + rootField.getText().trim(), + typeCombo.getValue(), + formatCombo.getValue(), + codecCombo.getValue(), + preloadField.isSelected()))); + stage.close(); + } catch (RuntimeException runtimeException) { + feedbackLabel.setText(Objects.requireNonNullElse(runtimeException.getMessage(), "Unable to create asset.")); + } + } + + private void browseForRoot() { + final DirectoryChooser chooser = new DirectoryChooser(); + chooser.setTitle(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_BROWSE_TITLE)); + final File initialDirectory = AssetRootValidator.assetsRoot(projectReference).toFile(); + if (initialDirectory.isDirectory()) { + chooser.setInitialDirectory(initialDirectory); + } else if (projectReference.rootPath().toFile().isDirectory()) { + chooser.setInitialDirectory(projectReference.rootPath().toFile()); + } + final File selected = chooser.showDialog(stage); + if (selected == null) { + return; + } + + final AssetRootValidationResult validation = AssetRootValidator.fromAbsoluteDirectory(projectReference, selected.toPath()); + if (!validation.valid()) { + feedbackLabel.setText(validation.message()); + return; + } + rootField.setText(validation.normalizedRelativeRoot()); + feedbackLabel.setText(""); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorFilter.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorFilter.java index 4fd207a8..729c96de 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorFilter.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorFilter.java @@ -1,8 +1,8 @@ package p.studio.workspaces.assets; public enum AssetNavigatorFilter { - MANAGED, - ORPHAN, + REGISTERED, + UNREGISTERED, DIAGNOSTICS, PRELOAD } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilder.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilder.java index 978564cd..810b1b36 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilder.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilder.java @@ -44,11 +44,11 @@ public final class AssetNavigatorProjectionBuilder { return true; } - final boolean includeManaged = filters.contains(AssetNavigatorFilter.MANAGED); - final boolean includeOrphan = filters.contains(AssetNavigatorFilter.ORPHAN); - if (includeManaged || includeOrphan) { - final boolean stateMatches = (includeManaged && asset.state() == AssetWorkspaceAssetState.MANAGED) - || (includeOrphan && asset.state() == AssetWorkspaceAssetState.ORPHAN); + final boolean includeRegistered = filters.contains(AssetNavigatorFilter.REGISTERED); + final boolean includeUnregistered = filters.contains(AssetNavigatorFilter.UNREGISTERED); + if (includeRegistered || includeUnregistered) { + final boolean stateMatches = (includeRegistered && asset.state() == AssetWorkspaceAssetState.REGISTERED) + || (includeUnregistered && asset.state() == AssetWorkspaceAssetState.UNREGISTERED); if (!stateMatches) { return false; } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRelocationTargetValidator.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRelocationTargetValidator.java new file mode 100644 index 00000000..b9e89eb6 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRelocationTargetValidator.java @@ -0,0 +1,113 @@ +package p.studio.workspaces.assets; + +import p.studio.projects.ProjectReference; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +final class AssetRelocationTargetValidator { + private static final String CONTROL_DIR = ".prometeu"; + + private AssetRelocationTargetValidator() { + } + + static AssetRootValidationResult validate( + ProjectReference projectReference, + Path currentAssetRoot, + String parentRelativeRootText, + String destinationName) { + final String normalizedName = normalizeDestinationName(destinationName); + if (normalizedName == null) { + return AssetRootValidationResult.failure("Destination folder name is required."); + } + + final Path parentRelativeRoot; + try { + parentRelativeRoot = normalizeParentRelativeRoot(parentRelativeRootText); + } catch (RuntimeException runtimeException) { + return AssetRootValidationResult.failure("Destination parent must stay inside assets/."); + } + + final String relativeRootText = parentRelativeRoot.getNameCount() == 0 + ? normalizedName + : parentRelativeRoot.resolve(normalizedName).toString().replace('\\', '/'); + return validate(projectReference, currentAssetRoot, AssetRootValidator.assetsRoot(projectReference).resolve(relativeRootText)); + } + + static AssetRootValidationResult validate( + ProjectReference projectReference, + Path currentAssetRoot, + Path targetRoot) { + Objects.requireNonNull(projectReference, "projectReference"); + Objects.requireNonNull(currentAssetRoot, "currentAssetRoot"); + if (targetRoot == null) { + return AssetRootValidationResult.failure("Relocation target is required."); + } + + final Path assetsRoot = AssetRootValidator.assetsRoot(projectReference); + final Path normalizedTargetRoot = targetRoot.toAbsolutePath().normalize(); + if (!normalizedTargetRoot.startsWith(assetsRoot)) { + return AssetRootValidationResult.failure("Relocation target must stay inside assets/."); + } + + final Path relativeRoot = assetsRoot.relativize(normalizedTargetRoot); + if (relativeRoot.getNameCount() == 0) { + return AssetRootValidationResult.failure("Relocation target must point to an asset root inside assets/."); + } + if (CONTROL_DIR.equals(relativeRoot.getName(0).toString())) { + return AssetRootValidationResult.failure("Relocation target must not use the reserved .prometeu directory."); + } + + final Path normalizedCurrentRoot = currentAssetRoot.toAbsolutePath().normalize(); + if (normalizedTargetRoot.equals(normalizedCurrentRoot)) { + return AssetRootValidationResult.failure("Relocation target must be different from the current asset root."); + } + if (normalizedTargetRoot.startsWith(normalizedCurrentRoot)) { + return AssetRootValidationResult.failure("Relocation target must not be inside the current asset root."); + } + if (Files.exists(normalizedTargetRoot)) { + return AssetRootValidationResult.failure("Relocation target already exists: " + + relativeRoot.toString().replace('\\', '/')); + } + + return AssetRootValidationResult.success(relativeRoot.toString().replace('\\', '/'), normalizedTargetRoot); + } + + static String normalizeParentForDisplay(Path assetsRoot, Path parentDirectory) { + final Path normalizedAssetsRoot = Objects.requireNonNull(assetsRoot, "assetsRoot").toAbsolutePath().normalize(); + final Path normalizedParent = Objects.requireNonNull(parentDirectory, "parentDirectory").toAbsolutePath().normalize(); + if (!normalizedParent.startsWith(normalizedAssetsRoot)) { + throw new IllegalArgumentException("Parent directory must stay inside assets/"); + } + final Path relativeParent = normalizedAssetsRoot.relativize(normalizedParent); + return relativeParent.getNameCount() == 0 ? "." : relativeParent.toString().replace('\\', '/'); + } + + private static Path normalizeParentRelativeRoot(String parentRelativeRootText) { + final String trimmed = Objects.requireNonNullElse(parentRelativeRootText, "").trim(); + if (trimmed.isBlank() || ".".equals(trimmed)) { + return Path.of(""); + } + final Path relativeRoot = Path.of(trimmed).normalize(); + if (relativeRoot.isAbsolute() || relativeRoot.startsWith("..")) { + throw new IllegalArgumentException("Parent directory must stay inside assets/"); + } + if (relativeRoot.getNameCount() > 0 && CONTROL_DIR.equals(relativeRoot.getName(0).toString())) { + throw new IllegalArgumentException("Reserved directory"); + } + return relativeRoot; + } + + private static String normalizeDestinationName(String destinationName) { + final String trimmed = Objects.requireNonNullElse(destinationName, "").trim(); + if (trimmed.isBlank()) { + return null; + } + final Path segment = Path.of(trimmed).normalize(); + if (segment.isAbsolute() || segment.getNameCount() != 1 || ".".equals(trimmed) || "..".equals(trimmed)) { + return null; + } + return segment.getFileName().toString(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRelocationWizard.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRelocationWizard.java new file mode 100644 index 00000000..7a4223b2 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRelocationWizard.java @@ -0,0 +1,417 @@ +package p.studio.workspaces.assets; + +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.stage.DirectoryChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; +import javafx.stage.Window; +import p.studio.Container; +import p.studio.projects.ProjectReference; +import p.studio.utilities.i18n.I18n; + +import java.io.File; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +public final class AssetRelocationWizard { + private final ProjectReference projectReference; + private final AssetWorkspaceAssetSummary asset; + private final AssetWorkspaceMutationService mutationService; + private final Stage stage; + private final AtomicReference result = new AtomicReference<>(); + + private final Label stepTitle = new Label(); + private final Label stepDescription = new Label(); + private final VBox stepBody = new VBox(12); + private final TextField parentField = new TextField(); + private final TextField nameField = new TextField(); + private final Label targetRootValue = new Label("—"); + private final Label feedbackLabel = new Label(); + private final Button backButton = new Button(); + private final Button nextButton = new Button(); + private final Button confirmButton = new Button(); + private final Button cancelButton = new Button(); + + private AssetWorkspaceMutationPreview preview; + private int stepIndex = 0; + + private AssetRelocationWizard( + Window owner, + ProjectReference projectReference, + AssetWorkspaceAssetSummary asset, + AssetWorkspaceMutationService mutationService) { + this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); + this.asset = Objects.requireNonNull(asset, "asset"); + this.mutationService = Objects.requireNonNull(mutationService, "mutationService"); + this.stage = new Stage(); + stage.initOwner(owner); + stage.initModality(Modality.WINDOW_MODAL); + stage.setTitle(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_TITLE)); + stage.setScene(new Scene(buildRoot(), 640, 440)); + stage.getScene().getStylesheets().add(Container.theme().getDefaultTheme()); + + configureDefaults(); + configureValidation(); + renderStep(); + } + + public static Optional showAndWait( + Window owner, + ProjectReference projectReference, + AssetWorkspaceAssetSummary asset, + AssetWorkspaceMutationService mutationService) { + final AssetRelocationWizard wizard = new AssetRelocationWizard(owner, projectReference, asset, mutationService); + wizard.stage.showAndWait(); + return Optional.ofNullable(wizard.result.get()); + } + + private VBox buildRoot() { + stepTitle.getStyleClass().add("studio-launcher-section-title"); + stepDescription.getStyleClass().add("studio-launcher-subtitle"); + stepDescription.setWrapText(true); + + feedbackLabel.getStyleClass().add("studio-launcher-feedback"); + feedbackLabel.setWrapText(true); + + backButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BACK)); + backButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); + backButton.setOnAction(ignored -> goBack()); + + nextButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_NEXT)); + nextButton.getStyleClass().addAll("studio-button", "studio-button-primary"); + nextButton.setOnAction(ignored -> goNext()); + + confirmButton.textProperty().bind(Container.i18n().bind(I18n.ASSETS_RELOCATE_WIZARD_BUTTON_CONFIRM)); + confirmButton.getStyleClass().addAll("studio-button", "studio-button-primary"); + confirmButton.setOnAction(ignored -> confirm()); + + cancelButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_CANCEL)); + cancelButton.getStyleClass().addAll("studio-button", "studio-button-cancel"); + cancelButton.setOnAction(ignored -> stage.close()); + + final Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + final HBox actions = new HBox(12, backButton, spacer, cancelButton, nextButton, confirmButton); + actions.setAlignment(Pos.CENTER_RIGHT); + + final VBox root = new VBox(16, stepTitle, stepDescription, stepBody, feedbackLabel, actions); + root.setPadding(new Insets(24)); + VBox.setVgrow(stepBody, Priority.ALWAYS); + return root; + } + + private Node field(String labelText, Node control) { + final Label label = new Label(labelText); + final VBox box = new VBox(6, label, control); + VBox.setVgrow(control, Priority.NEVER); + return box; + } + + private void configureDefaults() { + final Path assetsRoot = AssetRootValidator.assetsRoot(projectReference); + final Path currentRoot = asset.assetRoot().toAbsolutePath().normalize(); + final Path currentParent = currentRoot.getParent() == null ? assetsRoot : currentRoot.getParent(); + parentField.setText(AssetRelocationTargetValidator.normalizeParentForDisplay(assetsRoot, currentParent)); + nameField.setPromptText(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_PROMPT_DESTINATION_NAME)); + final String currentLeaf = currentRoot.getFileName() == null ? "" : currentRoot.getFileName().toString(); + nameField.setText(currentLeaf); + } + + private void configureValidation() { + parentField.textProperty().addListener((ignored, oldValue, newValue) -> refreshValidation()); + nameField.textProperty().addListener((ignored, oldValue, newValue) -> refreshValidation()); + } + + private void renderStep() { + feedbackLabel.setText(""); + backButton.setDisable(stepIndex == 0); + nextButton.setVisible(stepIndex == 0); + nextButton.setManaged(stepIndex == 0); + confirmButton.setVisible(stepIndex == 1); + confirmButton.setManaged(stepIndex == 1); + + switch (stepIndex) { + case 0 -> renderDestinationStep(); + case 1 -> renderSummaryStep(); + default -> throw new IllegalStateException("Unknown relocation step: " + stepIndex); + } + } + + private void renderDestinationStep() { + stepTitle.setText(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_STEP_DESTINATION_TITLE)); + stepDescription.setText(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_STEP_DESTINATION_DESCRIPTION)); + + final Button browseButton = new Button(); + browseButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BROWSE)); + browseButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); + browseButton.setOnAction(ignored -> browseForParent()); + + final HBox parentRow = new HBox(12, parentField, browseButton); + parentRow.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(parentField, Priority.ALWAYS); + + final Label currentRootValue = new Label(relativeAssetPath(asset.assetRoot())); + currentRootValue.getStyleClass().add("assets-details-value"); + currentRootValue.setWrapText(true); + + targetRootValue.getStyleClass().add("assets-details-value"); + targetRootValue.setWrapText(true); + + stepBody.getChildren().setAll( + field(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_LABEL_CURRENT_ROOT), currentRootValue), + field(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_LABEL_DESTINATION_PARENT), parentRow), + field(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_LABEL_DESTINATION_NAME), nameField), + field(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_LABEL_TARGET_ROOT), targetRootValue)); + refreshValidation(); + } + + private void renderSummaryStep() { + stepTitle.setText(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_STEP_SUMMARY_TITLE)); + stepDescription.setText(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_STEP_SUMMARY_DESCRIPTION)); + + final AssetWorkspaceMutationPreview currentPreview = Objects.requireNonNull(preview, "preview"); + final Label note = new Label(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_NOTE)); + note.getStyleClass().add("studio-launcher-subtitle"); + note.setWrapText(true); + + final VBox summary = new VBox(10); + summary.getChildren().addAll( + createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_AFFECTED_ASSET), + createAffectedAssetContent(currentPreview)), + createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_REGISTRY_IMPACT), + createMutationChangesContent( + AssetWorkspaceMutationImpactViewModel.from(currentPreview).registryChanges(), + Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_REGISTRY_IMPACT))), + createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_WORKSPACE_IMPACT), + createMutationChangesContent( + AssetWorkspaceMutationImpactViewModel.from(currentPreview).workspaceChanges(), + Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_WORKSPACE_IMPACT))), + createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_BLOCKERS), + createMutationMessages( + currentPreview.blockers(), + "assets-mutation-message-blocker", + Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_BLOCKERS))), + createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_WARNINGS), + createMutationMessages( + currentPreview.warnings(), + "assets-mutation-message-warning", + Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_WARNINGS))), + createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_SAFE_FIXES), + createMutationMessages( + currentPreview.safeFixes(), + "assets-mutation-message-safe-fix", + Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_SAFE_FIXES))), + note); + stepBody.getChildren().setAll(summary); + confirmButton.setDisable(!currentPreview.canApply()); + } + + private void refreshValidation() { + final AssetRootValidationResult validation = currentValidation(); + if (!validation.valid()) { + targetRootValue.setText("—"); + feedbackLabel.setText(validation.message()); + nextButton.setDisable(true); + confirmButton.setDisable(true); + return; + } + targetRootValue.setText(validation.normalizedRelativeRoot()); + feedbackLabel.setText(""); + nextButton.setDisable(false); + confirmButton.setDisable(false); + } + + private void browseForParent() { + final DirectoryChooser chooser = new DirectoryChooser(); + chooser.setTitle(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_BROWSE_TITLE)); + final File initialDirectory = initialParentDirectory().toFile(); + if (initialDirectory.isDirectory()) { + chooser.setInitialDirectory(initialDirectory); + } + final File selected = chooser.showDialog(stage); + if (selected == null) { + return; + } + + final Path selectedDirectory = selected.toPath().toAbsolutePath().normalize(); + final Path assetsRoot = AssetRootValidator.assetsRoot(projectReference); + if (!selectedDirectory.startsWith(assetsRoot)) { + feedbackLabel.setText("Destination parent must stay inside assets/."); + return; + } + final Path relativeParent = assetsRoot.relativize(selectedDirectory); + if (relativeParent.getNameCount() > 0 && ".prometeu".equals(relativeParent.getName(0).toString())) { + feedbackLabel.setText("Destination parent must not use the reserved .prometeu directory."); + return; + } + + parentField.setText(AssetRelocationTargetValidator.normalizeParentForDisplay(assetsRoot, selectedDirectory)); + refreshValidation(); + } + + private Path initialParentDirectory() { + final Path assetsRoot = AssetRootValidator.assetsRoot(projectReference); + try { + final String parentText = parentField.getText(); + if (parentText == null || parentText.isBlank() || ".".equals(parentText.trim())) { + return assetsRoot; + } + final Path candidate = assetsRoot.resolve(parentField.getText().trim()).toAbsolutePath().normalize(); + return candidate.startsWith(assetsRoot) && candidate.toFile().isDirectory() ? candidate : assetsRoot; + } catch (RuntimeException runtimeException) { + return assetsRoot; + } + } + + private void goBack() { + if (stepIndex == 0) { + return; + } + preview = null; + stepIndex -= 1; + renderStep(); + } + + private void goNext() { + final AssetRootValidationResult validation = currentValidation(); + if (!validation.valid()) { + feedbackLabel.setText(validation.message()); + nextButton.setDisable(true); + return; + } + try { + preview = mutationService.preview(projectReference, asset, AssetWorkspaceAction.RELOCATE, validation.assetRoot()); + } catch (RuntimeException runtimeException) { + preview = null; + feedbackLabel.setText(Objects.requireNonNullElse(runtimeException.getMessage(), "Unable to preview relocation.")); + return; + } + stepIndex = 1; + renderStep(); + } + + private void confirm() { + final AssetWorkspaceMutationPreview currentPreview = preview; + if (currentPreview == null) { + feedbackLabel.setText("Relocation preview is required before confirmation."); + confirmButton.setDisable(true); + return; + } + if (!currentPreview.canApply()) { + feedbackLabel.setText(currentPreview.blockers().isEmpty() + ? "Relocation cannot be applied." + : currentPreview.blockers().getFirst()); + confirmButton.setDisable(true); + return; + } + try { + mutationService.apply(projectReference, currentPreview); + result.set(currentPreview); + stage.close(); + } catch (RuntimeException runtimeException) { + feedbackLabel.setText(Objects.requireNonNullElse(runtimeException.getMessage(), "Unable to apply relocation.")); + } + } + + private AssetRootValidationResult currentValidation() { + return AssetRelocationTargetValidator.validate( + projectReference, + asset.assetRoot(), + parentField.getText(), + nameField.getText()); + } + + private Node createMutationSection(String title, Node content) { + final VBox section = new VBox(6); + final Label label = new Label(title); + label.getStyleClass().add("assets-mutation-section-title"); + section.getChildren().addAll(label, content); + return section; + } + + private Node createAffectedAssetContent(AssetWorkspaceMutationPreview currentPreview) { + final VBox box = new VBox(6); + box.getChildren().add(keyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_NAME), currentPreview.asset().assetName())); + box.getChildren().add(keyValueRow( + Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), + relativeAssetPath(currentPreview.asset().assetRoot()))); + if (currentPreview.targetAssetRoot() != null) { + box.getChildren().add(keyValueRow( + Container.i18n().text(I18n.ASSETS_LABEL_TARGET_LOCATION), + relativeAssetPath(currentPreview.targetAssetRoot()))); + } + return box; + } + + private Node createMutationChangesContent(java.util.List changes, String emptyText) { + if (changes.isEmpty()) { + final Label label = new Label(emptyText); + label.setWrapText(true); + label.getStyleClass().add("assets-details-section-message"); + return label; + } + final VBox box = new VBox(6); + for (AssetWorkspaceMutationChange change : changes) { + final Label label = new Label(change.verb() + " · " + change.target()); + label.getStyleClass().add("assets-mutation-change"); + box.getChildren().add(label); + } + return box; + } + + private Node createMutationMessages(java.util.List messages, String styleClass, String emptyText) { + if (messages.isEmpty()) { + final Label label = new Label(emptyText); + label.setWrapText(true); + label.getStyleClass().add("assets-details-section-message"); + return label; + } + final VBox box = new VBox(6); + for (String message : messages) { + final Label label = new Label(message); + label.setWrapText(true); + label.getStyleClass().add(styleClass); + box.getChildren().add(label); + } + return box; + } + + private Node keyValueRow(String key, String value) { + final HBox row = new HBox(12); + final Label keyLabel = new Label(key); + keyLabel.getStyleClass().add("assets-details-key"); + final Label valueLabel = new Label(value == null || value.isBlank() ? "—" : value); + valueLabel.getStyleClass().add("assets-details-value"); + valueLabel.setWrapText(true); + HBox.setHgrow(valueLabel, Priority.ALWAYS); + row.getChildren().addAll(keyLabel, valueLabel); + return row; + } + + private String relativeAssetPath(Path assetRoot) { + final Path assetsRoot = AssetRootValidator.assetsRoot(projectReference); + final Path normalizedAssetRoot = assetRoot.toAbsolutePath().normalize(); + if (!normalizedAssetRoot.startsWith(assetsRoot)) { + return normalizedAssetRoot.toString(); + } + return assetsRoot.relativize(normalizedAssetRoot).toString().replace('\\', '/'); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRootValidationResult.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRootValidationResult.java new file mode 100644 index 00000000..9dabc923 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRootValidationResult.java @@ -0,0 +1,25 @@ +package p.studio.workspaces.assets; + +import java.nio.file.Path; +import java.util.Objects; + +public record AssetRootValidationResult( + boolean valid, + String normalizedRelativeRoot, + Path assetRoot, + String message) { + + public AssetRootValidationResult { + normalizedRelativeRoot = Objects.requireNonNullElse(normalizedRelativeRoot, ""); + message = Objects.requireNonNullElse(message, ""); + assetRoot = assetRoot == null ? null : assetRoot.toAbsolutePath().normalize(); + } + + public static AssetRootValidationResult success(String normalizedRelativeRoot, Path assetRoot) { + return new AssetRootValidationResult(true, normalizedRelativeRoot, assetRoot, ""); + } + + public static AssetRootValidationResult failure(String message) { + return new AssetRootValidationResult(false, "", null, message); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRootValidator.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRootValidator.java new file mode 100644 index 00000000..91eee3ef --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRootValidator.java @@ -0,0 +1,60 @@ +package p.studio.workspaces.assets; + +import p.packer.api.PackerProjectContext; +import p.packer.foundation.PackerWorkspacePaths; +import p.studio.projects.ProjectReference; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +final class AssetRootValidator { + private static final String ASSET_MANIFEST = "asset.json"; + private static final String CONTROL_DIR = ".prometeu"; + + private AssetRootValidator() { + } + + static Path assetsRoot(ProjectReference projectReference) { + final ProjectReference reference = Objects.requireNonNull(projectReference, "projectReference"); + return PackerWorkspacePaths.assetsRoot(new PackerProjectContext(reference.name(), reference.rootPath())); + } + + static AssetRootValidationResult validate(ProjectReference projectReference, String relativeRootText) { + final Path relativeRoot; + try { + relativeRoot = Path.of(Objects.requireNonNullElse(relativeRootText, "").trim()).normalize(); + } catch (RuntimeException runtimeException) { + return AssetRootValidationResult.failure("Asset root must be a valid relative path."); + } + + if (relativeRootText == null || relativeRootText.isBlank() || relativeRoot.getNameCount() == 0 || relativeRoot.isAbsolute() || relativeRoot.startsWith("..")) { + return AssetRootValidationResult.failure("Asset root must stay inside assets/."); + } + if (CONTROL_DIR.equals(relativeRoot.getName(0).toString())) { + return AssetRootValidationResult.failure("Asset root must not target the reserved .prometeu directory."); + } + + final Path assetsRoot = assetsRoot(projectReference); + final Path assetRoot = assetsRoot.resolve(relativeRoot).toAbsolutePath().normalize(); + if (!assetRoot.startsWith(assetsRoot)) { + return AssetRootValidationResult.failure("Asset root must stay inside assets/."); + } + if (Files.isRegularFile(assetRoot.resolve(ASSET_MANIFEST))) { + return AssetRootValidationResult.failure("asset.json already exists at: " + relativeRoot.toString().replace('\\', '/')); + } + return AssetRootValidationResult.success(relativeRoot.toString().replace('\\', '/'), assetRoot); + } + + static AssetRootValidationResult fromAbsoluteDirectory(ProjectReference projectReference, Path directory) { + if (directory == null) { + return AssetRootValidationResult.failure("Asset root is required."); + } + final Path assetsRoot = assetsRoot(projectReference); + final Path normalized = directory.toAbsolutePath().normalize(); + if (!normalized.startsWith(assetsRoot)) { + return AssetRootValidationResult.failure("Choose a directory inside assets/."); + } + return validate(projectReference, assetsRoot.relativize(normalized).toString().replace('\\', '/')); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java index cafe3896..1a6b29a4 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java @@ -1,5 +1,8 @@ package p.studio.workspaces.assets; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import javafx.application.Platform; import javafx.geometry.Insets; import javafx.geometry.Orientation; @@ -9,7 +12,15 @@ import javafx.scene.control.*; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.*; +import p.packer.api.PackerProjectContext; +import p.packer.api.building.PackerBuildRequest; +import p.packer.api.building.PackerBuildResult; +import p.packer.api.doctor.PackerDoctorMode; +import p.packer.api.doctor.PackerDoctorRequest; +import p.packer.api.doctor.PackerDoctorResult; +import p.packer.building.FileSystemPackerBuildService; import p.packer.declarations.PackerAssetDeclarationParser; +import p.packer.doctor.FileSystemPackerDoctorService; import p.packer.foundation.PackerWorkspaceFoundation; import p.packer.workspace.FileSystemPackerWorkspaceService; import p.studio.Container; @@ -26,14 +37,22 @@ import java.util.*; import java.util.concurrent.CompletableFuture; public final class AssetWorkspace implements Workspace { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final BorderPane root = new BorderPane(); private final ProjectReference projectReference; private final AssetWorkspaceService assetWorkspaceService; private final AssetWorkspaceMutationService mutationService; + private final AssetCreationService assetCreationService; + private final FileSystemPackerDoctorService doctorService; + private final FileSystemPackerBuildService packService; private final StudioWorkspaceEventBus workspaceBus; private final TextField searchField = new TextField(); private final FlowPane filterBar = new FlowPane(); + private final Button addAssetButton = new Button(); + private final Button doctorButton = new Button(); + private final Button packButton = new Button(); private final Label navigatorStateLabel = new Label(); private final VBox navigatorContent = new VBox(8); private final Label inlineProgressLabel = new Label(); @@ -45,6 +64,7 @@ public final class AssetWorkspace implements Workspace { private final ScrollPane detailsScroll = new ScrollPane(); private final Map filterButtons = new EnumMap<>(AssetNavigatorFilter.class); + private final Map assetRowsBySelectionKey = new HashMap<>(); private final EnumSet activeFilters = EnumSet.noneOf(AssetNavigatorFilter.class); private volatile AssetWorkspaceState state = AssetWorkspaceState.loading(null); @@ -54,6 +74,7 @@ public final class AssetWorkspace implements Workspace { private volatile AssetWorkspaceMutationPreview stagedMutationPreview; private volatile Path selectedPreviewInput; private volatile int selectedPreviewZoom = 1; + private volatile AssetWorkspaceSelectionKey pendingSelectionKey; private String searchQuery = ""; public AssetWorkspace(ProjectReference projectReference) { @@ -61,6 +82,7 @@ public final class AssetWorkspace implements Workspace { projectReference, null, defaultWorkspaceBus(), + null, null); } @@ -68,25 +90,38 @@ public final class AssetWorkspace implements Workspace { ProjectReference projectReference, AssetWorkspaceService assetWorkspaceService, AssetWorkspaceMutationService mutationService) { - this(projectReference, assetWorkspaceService, defaultWorkspaceBus(), mutationService); + this(projectReference, assetWorkspaceService, defaultWorkspaceBus(), mutationService, null); } private AssetWorkspace( ProjectReference projectReference, AssetWorkspaceService assetWorkspaceService, StudioWorkspaceEventBus workspaceBus, - AssetWorkspaceMutationService mutationService) { + AssetWorkspaceMutationService mutationService, + AssetCreationService assetCreationService) { this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); + final StudioPackerEventAdapter packerEventAdapter = new StudioPackerEventAdapter(this.workspaceBus, this.projectReference); this.assetWorkspaceService = assetWorkspaceService == null ? new PackerBackedAssetWorkspaceService(new FileSystemPackerWorkspaceService( new PackerWorkspaceFoundation(), new PackerAssetDeclarationParser(), - new StudioPackerEventAdapter(this.workspaceBus, this.projectReference))) + packerEventAdapter)) : Objects.requireNonNull(assetWorkspaceService, "assetWorkspaceService"); this.mutationService = mutationService == null ? new PackerBackedAssetWorkspaceMutationService(this.workspaceBus) : Objects.requireNonNull(mutationService, "mutationService"); + this.assetCreationService = assetCreationService == null + ? new PackerBackedAssetCreationService() + : Objects.requireNonNull(assetCreationService, "assetCreationService"); + this.doctorService = new FileSystemPackerDoctorService( + new FileSystemPackerWorkspaceService( + new PackerWorkspaceFoundation(), + new PackerAssetDeclarationParser(), + packerEventAdapter), + new p.packer.declarations.PackerAssetDetailsService(), + packerEventAdapter); + this.packService = new FileSystemPackerBuildService(new p.packer.building.PackerBuildPlanner(), packerEventAdapter); root.getStyleClass().add("assets-workspace"); root.setCenter(buildLayout()); @@ -149,13 +184,28 @@ public final class AssetWorkspace implements Workspace { }); configureFilterBar(); + addAssetButton.textProperty().bind(Container.i18n().bind(I18n.ASSETS_NAVIGATOR_ACTION_ADD)); + addAssetButton.getStyleClass().addAll("studio-button", "studio-button-primary"); + addAssetButton.setMaxWidth(Double.MAX_VALUE); + addAssetButton.setOnAction(event -> openAddAssetWizard()); + doctorButton.textProperty().bind(Container.i18n().bind(I18n.ASSETS_NAVIGATOR_ACTION_DOCTOR)); + doctorButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); + doctorButton.setOnAction(event -> runDoctor()); + packButton.textProperty().bind(Container.i18n().bind(I18n.ASSETS_NAVIGATOR_ACTION_PACK)); + packButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); + packButton.setOnAction(event -> runPack()); navigatorStateLabel.getStyleClass().add("assets-workspace-pane-body"); navigatorContent.getStyleClass().add("assets-workspace-navigator-content"); final ScrollPane navigatorScroll = new ScrollPane(navigatorContent); navigatorScroll.setFitToWidth(true); navigatorScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); navigatorScroll.getStyleClass().add("assets-workspace-navigator-scroll"); - navigatorPane.getChildren().addAll(navigatorTitle, searchField, filterBar, navigatorStateLabel, navigatorScroll); + navigatorPane.getChildren().addAll( + navigatorTitle, + searchField, + filterBar, + navigatorStateLabel, + navigatorScroll); VBox.setVgrow(navigatorScroll, Priority.ALWAYS); final VBox detailsPane = new VBox(10); @@ -175,7 +225,10 @@ public final class AssetWorkspace implements Workspace { final SplitPane splitPane = new SplitPane(navigatorPane, detailsPane); splitPane.setDividerPositions(0.34); splitPane.getStyleClass().add("assets-workspace-split"); - final VBox layout = new VBox(10, topProgress, splitPane); + final HBox workspaceActionBar = new HBox(8, addAssetButton, doctorButton, packButton); + workspaceActionBar.setAlignment(Pos.CENTER_LEFT); + workspaceActionBar.getStyleClass().add("assets-workspace-action-bar"); + final VBox layout = new VBox(10, topProgress, workspaceActionBar, splitPane); VBox.setVgrow(splitPane, Priority.ALWAYS); return layout; } @@ -200,8 +253,8 @@ public final class AssetWorkspace implements Workspace { filterBar.setVgap(6); filterBar.setPadding(new Insets(4, 0, 4, 0)); filterBar.getStyleClass().add("assets-workspace-filter-bar"); - addFilterButton(AssetNavigatorFilter.MANAGED, I18n.ASSETS_FILTER_MANAGED); - addFilterButton(AssetNavigatorFilter.ORPHAN, I18n.ASSETS_FILTER_ORPHAN); + addFilterButton(AssetNavigatorFilter.REGISTERED, I18n.ASSETS_FILTER_REGISTERED); + addFilterButton(AssetNavigatorFilter.UNREGISTERED, I18n.ASSETS_FILTER_UNREGISTERED); addFilterButton(AssetNavigatorFilter.DIAGNOSTICS, I18n.ASSETS_FILTER_DIAGNOSTICS); addFilterButton(AssetNavigatorFilter.PRELOAD, I18n.ASSETS_FILTER_PRELOAD); } @@ -209,7 +262,7 @@ public final class AssetWorkspace implements Workspace { private void addFilterButton(AssetNavigatorFilter filter, I18n i18n) { final ToggleButton button = new ToggleButton(); button.textProperty().bind(Container.i18n().bind(i18n)); - button.getStyleClass().add("assets-workspace-filter-button"); + button.getStyleClass().addAll("studio-button", "studio-button-secondary", "studio-button-pill", "studio-button-toggle"); button.selectedProperty().addListener((ignored, oldValue, selected) -> { if (selected) { activeFilters.add(filter); @@ -223,54 +276,73 @@ public final class AssetWorkspace implements Workspace { } private void refresh() { - state = AssetWorkspaceState.loading(state); - detailsStatus = AssetWorkspaceDetailsStatus.EMPTY; - selectedAssetDetails = null; - detailsErrorMessage = null; - stagedMutationPreview = null; - selectedPreviewInput = null; - selectedPreviewZoom = 1; + final boolean preserveVisibleContent = hasVisibleWorkspaceContent(); + if (!preserveVisibleContent) { + state = AssetWorkspaceState.loading(state); + detailsStatus = AssetWorkspaceDetailsStatus.EMPTY; + selectedAssetDetails = null; + detailsErrorMessage = null; + stagedMutationPreview = null; + selectedPreviewInput = null; + selectedPreviewZoom = 1; + renderState(); + } setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_REFRESHING), ProgressBar.INDETERMINATE_PROGRESS, true); appendLog("Assets refresh started."); - renderState(); workspaceBus.publish(new StudioAssetsWorkspaceRefreshStartedEvent(projectReference)); CompletableFuture .supplyAsync(() -> assetWorkspaceService.loadWorkspace(projectReference)) .whenComplete((snapshot, throwable) -> Platform.runLater(() -> { if (throwable != null) { - state = AssetWorkspaceState.error(state, rootCauseMessage(throwable)); - detailsStatus = AssetWorkspaceDetailsStatus.ERROR; - detailsErrorMessage = state.errorMessage(); + pendingSelectionKey = null; + if (!preserveVisibleContent) { + state = AssetWorkspaceState.error(state, rootCauseMessage(throwable)); + detailsStatus = AssetWorkspaceDetailsStatus.ERROR; + detailsErrorMessage = state.errorMessage(); + } setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); - appendLog("Assets refresh failed: " + state.errorMessage()); + appendLog("Assets refresh failed: " + rootCauseMessage(throwable)); renderState(); - workspaceBus.publish(new StudioAssetsWorkspaceRefreshFailedEvent(projectReference, state.errorMessage())); + workspaceBus.publish(new StudioAssetsWorkspaceRefreshFailedEvent(projectReference, rootCauseMessage(throwable))); return; } - state = AssetWorkspaceState.ready(snapshot.assets(), state.selectedKey()); + final AssetWorkspaceSelectionKey preferredSelection = pendingSelectionKey != null + ? pendingSelectionKey + : state.selectedKey(); + state = AssetWorkspaceState.ready(snapshot.assets(), preferredSelection); + pendingSelectionKey = null; setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); appendLog("Assets refresh completed: " + state.assets().size() + " assets."); renderState(); workspaceBus.publish(new StudioAssetsWorkspaceRefreshedEvent(projectReference, state.assets().size())); state.selectedAsset().ifPresent(asset -> { workspaceBus.publish(new StudioAssetsWorkspaceSelectionChangedEvent(projectReference, asset.selectionKey())); - loadSelectedAssetDetails(asset.selectionKey()); + loadSelectedAssetDetails(asset.selectionKey(), preserveVisibleContent); }); })); } private void loadSelectedAssetDetails(AssetWorkspaceSelectionKey selectionKey) { - detailsStatus = AssetWorkspaceDetailsStatus.LOADING; - selectedAssetDetails = null; - detailsErrorMessage = null; - stagedMutationPreview = null; - selectedPreviewInput = null; - selectedPreviewZoom = 1; + loadSelectedAssetDetails(selectionKey, false); + } + + private void loadSelectedAssetDetails(AssetWorkspaceSelectionKey selectionKey, boolean preserveExistingContent) { + final boolean preserveDetails = preserveExistingContent + && selectedAssetDetails != null + && selectionKey.equals(selectedAssetDetails.summary().selectionKey()); + if (!preserveDetails) { + detailsStatus = AssetWorkspaceDetailsStatus.LOADING; + selectedAssetDetails = null; + detailsErrorMessage = null; + stagedMutationPreview = null; + selectedPreviewInput = null; + selectedPreviewZoom = 1; + renderDetails(); + } setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_LOADING_DETAILS), ProgressBar.INDETERMINATE_PROGRESS, true); appendLog("Loading details for " + selectionKey.stableKey() + "."); - renderState(); CompletableFuture .supplyAsync(() -> assetWorkspaceService.loadAssetDetails(projectReference, selectionKey)) @@ -279,12 +351,15 @@ public final class AssetWorkspace implements Workspace { return; } if (throwable != null) { - detailsStatus = AssetWorkspaceDetailsStatus.ERROR; - detailsErrorMessage = rootCauseMessage(throwable); - selectedAssetDetails = null; + final String message = rootCauseMessage(throwable); + if (!preserveDetails) { + detailsStatus = AssetWorkspaceDetailsStatus.ERROR; + detailsErrorMessage = message; + selectedAssetDetails = null; + } setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); - appendLog("Asset details failed: " + detailsErrorMessage); - renderState(); + appendLog("Asset details failed: " + message); + renderDetails(); return; } @@ -294,16 +369,33 @@ public final class AssetWorkspace implements Workspace { selectedPreviewZoom = 1; setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); appendLog("Asset details ready for " + details.summary().assetName() + "."); - renderState(); + renderDetails(); })); } + private boolean hasVisibleWorkspaceContent() { + return !state.assets().isEmpty() && state.status() != AssetWorkspaceStatus.LOADING; + } + + private void openAddAssetWizard() { + if (root.getScene() == null || root.getScene().getWindow() == null) { + return; + } + AssetCreationWizard.showAndWait(root.getScene().getWindow(), projectReference, assetCreationService) + .ifPresent(result -> { + pendingSelectionKey = result.selectionKey(); + appendLog("Asset created: " + projectRelativePath(result.assetRoot()) + "."); + refresh(); + }); + } + private void renderState() { renderNavigator(); renderDetails(); } private void renderNavigator() { + assetRowsBySelectionKey.clear(); switch (state.status()) { case LOADING -> { navigatorStateLabel.setText(Container.i18n().text(I18n.ASSETS_STATE_LOADING)); @@ -359,7 +451,7 @@ public final class AssetWorkspace implements Workspace { } private void renderSelectedAssetDetails(AssetWorkspaceAssetSummary summary) { - detailsContent.getChildren().add(createSummarySection(summary)); + detailsContent.getChildren().add(createSummaryActionsRow(summary)); if (detailsStatus == AssetWorkspaceDetailsStatus.LOADING) { detailsContent.getChildren().add(createSection( Container.i18n().text(I18n.ASSETS_SECTION_RUNTIME_CONTRACT), @@ -370,9 +462,6 @@ public final class AssetWorkspace implements Workspace { detailsContent.getChildren().add(createSection( Container.i18n().text(I18n.ASSETS_SECTION_DIAGNOSTICS), createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING)))); - detailsContent.getChildren().add(createSection( - Container.i18n().text(I18n.ASSETS_SECTION_ACTIONS), - createActionsContent(summary))); return; } @@ -386,37 +475,56 @@ public final class AssetWorkspace implements Workspace { detailsContent.getChildren().add(createSection( Container.i18n().text(I18n.ASSETS_SECTION_DIAGNOSTICS), createSectionMessage(Objects.requireNonNullElse(detailsErrorMessage, Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY))))); - detailsContent.getChildren().add(createSection( - Container.i18n().text(I18n.ASSETS_SECTION_ACTIONS), - createActionsContent(summary))); return; } detailsContent.getChildren().add(createRuntimeContractSection(selectedAssetDetails)); detailsContent.getChildren().add(createInputsPreviewSection(selectedAssetDetails)); detailsContent.getChildren().add(createDiagnosticsSection(selectedAssetDetails)); - detailsContent.getChildren().add(createSection( - Container.i18n().text(I18n.ASSETS_SECTION_ACTIONS), - createActionsContent(summary))); } - private Node createSummarySection(AssetWorkspaceAssetSummary summary) { + private Node createSummaryActionsRow(AssetWorkspaceAssetSummary summary) { + final HBox row = new HBox(12); + row.getStyleClass().add("assets-details-summary-actions-row"); + + final VBox summarySection = createSummarySection(summary); + final VBox actionsSection = createActionsSection(summary); + HBox.setHgrow(summarySection, Priority.ALWAYS); + HBox.setHgrow(actionsSection, Priority.NEVER); + summarySection.setMaxWidth(Double.MAX_VALUE); + summarySection.setMinWidth(0); + actionsSection.setPrefWidth(280); + actionsSection.setMinWidth(240); + actionsSection.setMaxWidth(320); + + row.getChildren().addAll(summarySection, actionsSection); + return row; + } + + private VBox createSummarySection(AssetWorkspaceAssetSummary summary) { final VBox content = new VBox(8); content.getChildren().addAll( createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_NAME), summary.assetName()), - createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_STATE), summary.state().name().toLowerCase()), + createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_REGISTRATION), registrationLabel(summary.state())), + createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_BUILD_PARTICIPATION), buildParticipationLabel(summary.buildParticipation())), createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_ASSET_ID), summary.assetId() == null ? "—" : String.valueOf(summary.assetId())), createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_TYPE), summary.assetFamily()), createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), projectRelativePath(summary.assetRoot()))); return createSection(Container.i18n().text(I18n.ASSETS_SECTION_SUMMARY), content); } + private VBox createActionsSection(AssetWorkspaceAssetSummary summary) { + return createSection( + Container.i18n().text(I18n.ASSETS_SECTION_ACTIONS), + createActionsContent(summary)); + } + private Node createRuntimeContractSection(AssetWorkspaceAssetDetails details) { final VBox content = new VBox(8); content.getChildren().addAll( + createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_PRELOAD), createPreloadToggle(details)), createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_FORMAT), details.outputFormat()), - createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_CODEC), details.outputCodec()), - createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_PRELOAD), yesNo(details.summary().preload()))); + createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_CODEC), details.outputCodec())); return createSection(Container.i18n().text(I18n.ASSETS_SECTION_RUNTIME_CONTRACT), content); } @@ -440,9 +548,9 @@ public final class AssetWorkspace implements Workspace { roleBox.getChildren().add(roleLabel); for (Path input : entry.getValue()) { final Button inputButton = new Button(input.getFileName().toString()); - inputButton.getStyleClass().add("assets-details-input-button"); + inputButton.getStyleClass().addAll("assets-details-input-button", "studio-button", "studio-button-secondary"); if (input.equals(selectedPreviewInput)) { - inputButton.getStyleClass().add("assets-details-input-button-selected"); + inputButton.getStyleClass().add("studio-button-active"); } inputButton.setMaxWidth(Double.MAX_VALUE); inputButton.setOnAction(event -> { @@ -489,33 +597,12 @@ public final class AssetWorkspace implements Workspace { private Node createActionsContent(AssetWorkspaceAssetSummary summary) { final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(summary); final VBox content = new VBox(12); - - final VBox primaryBox = new VBox(8); - final Label primaryLabel = new Label(Container.i18n().text(I18n.ASSETS_ACTIONS_PRIMARY)); - primaryLabel.getStyleClass().add("assets-details-subsection-title"); - primaryBox.getChildren().add(primaryLabel); - final FlowPane primaryButtons = new FlowPane(); - primaryButtons.setHgap(8); - primaryButtons.setVgap(8); for (AssetWorkspaceAction action : actionSet.primaryActions()) { - primaryButtons.getChildren().add(createActionButton(action, false)); + content.getChildren().add(createActionButton(action, false)); } - primaryBox.getChildren().add(primaryButtons); - content.getChildren().add(primaryBox); - if (!actionSet.sensitiveActions().isEmpty()) { - final VBox sensitiveBox = new VBox(8); - final Label sensitiveLabel = new Label(Container.i18n().text(I18n.ASSETS_ACTIONS_SENSITIVE)); - sensitiveLabel.getStyleClass().add("assets-details-subsection-title"); - sensitiveBox.getChildren().add(sensitiveLabel); - final FlowPane sensitiveButtons = new FlowPane(); - sensitiveButtons.setHgap(8); - sensitiveButtons.setVgap(8); - for (AssetWorkspaceAction action : actionSet.sensitiveActions()) { - sensitiveButtons.getChildren().add(createActionButton(action, true)); - } - sensitiveBox.getChildren().add(sensitiveButtons); - content.getChildren().add(sensitiveBox); + for (AssetWorkspaceAction action : actionSet.sensitiveActions()) { + content.getChildren().add(createActionButton(action, true)); } if (stagedMutationPreview != null && stagedMutationPreview.asset().selectionKey().equals(summary.selectionKey())) { @@ -527,12 +614,8 @@ public final class AssetWorkspace implements Workspace { private Button createActionButton(AssetWorkspaceAction action, boolean sensitive) { final Button button = new Button(actionLabel(action)); - button.getStyleClass().add("assets-details-action-button"); - if (sensitive) { - button.getStyleClass().add("assets-details-action-button-sensitive"); - } else { - button.getStyleClass().add("assets-details-action-button-primary"); - } + button.getStyleClass().addAll("studio-button", actionButtonVariant(action, sensitive)); + button.setMaxWidth(Double.MAX_VALUE); button.setDisable(!supportsAction(action)); if (!button.isDisable()) { button.setOnAction(event -> requestMutationPreview(action)); @@ -540,15 +623,23 @@ public final class AssetWorkspace implements Workspace { return button; } + private String actionButtonVariant(AssetWorkspaceAction action, boolean sensitive) { + if (!sensitive) { + return "studio-button-primary"; + } + return switch (action) { + case RELOCATE -> "studio-button-warning"; + case EXCLUDE_FROM_BUILD, REMOVE -> "studio-button-danger"; + default -> "studio-button-secondary"; + }; + } + private String actionLabel(AssetWorkspaceAction action) { return switch (action) { - case DOCTOR -> Container.i18n().text(I18n.ASSETS_ACTION_DOCTOR); - case BUILD -> Container.i18n().text(I18n.ASSETS_ACTION_BUILD); - case ADOPT -> Container.i18n().text(I18n.ASSETS_ACTION_ADOPT); case REGISTER -> Container.i18n().text(I18n.ASSETS_ACTION_REGISTER); - case QUARANTINE -> Container.i18n().text(I18n.ASSETS_ACTION_QUARANTINE); + case INCLUDE_IN_BUILD -> Container.i18n().text(I18n.ASSETS_ACTION_INCLUDE_IN_BUILD); + case EXCLUDE_FROM_BUILD -> Container.i18n().text(I18n.ASSETS_ACTION_EXCLUDE_FROM_BUILD); case RELOCATE -> Container.i18n().text(I18n.ASSETS_ACTION_RELOCATE); - case FORGET -> Container.i18n().text(I18n.ASSETS_ACTION_FORGET); case REMOVE -> Container.i18n().text(I18n.ASSETS_ACTION_REMOVE); }; } @@ -616,7 +707,12 @@ public final class AssetWorkspace implements Workspace { final int maxZoom = maxPreviewZoom(image); for (int zoom : List.of(1, 2, 4, 8)) { final ToggleButton button = new ToggleButton("x" + zoom); - button.getStyleClass().add("assets-details-preview-zoom-button"); + button.getStyleClass().addAll( + "assets-details-preview-zoom-button", + "studio-button", + "studio-button-secondary", + "studio-button-pill", + "studio-button-toggle"); button.setToggleGroup(zoomGroup); button.setSelected(selectedPreviewZoom == zoom); button.setDisable(zoom > maxZoom); @@ -666,7 +762,7 @@ public final class AssetWorkspace implements Workspace { return Math.max(1, (int) Math.floor(420.0d / longestEdge)); } - private Node createSection(String title, Node content) { + private VBox createSection(String title, Node content) { final VBox section = new VBox(10); section.getStyleClass().add("assets-details-section"); final Label titleLabel = new Label(title); @@ -683,18 +779,102 @@ public final class AssetWorkspace implements Workspace { } private Node createKeyValueRow(String key, String value) { + final Label valueLabel = new Label(value); + valueLabel.getStyleClass().add("assets-details-value"); + valueLabel.setWrapText(true); + return createKeyValueRow(key, valueLabel); + } + + private Node createKeyValueRow(String key, Node valueNode) { final HBox row = new HBox(12); row.setAlignment(Pos.TOP_LEFT); final Label keyLabel = new Label(key); keyLabel.getStyleClass().add("assets-details-key"); - final Label valueLabel = new Label(value); - valueLabel.getStyleClass().add("assets-details-value"); - valueLabel.setWrapText(true); - HBox.setHgrow(valueLabel, Priority.ALWAYS); - row.getChildren().addAll(keyLabel, valueLabel); + HBox.setHgrow(valueNode, Priority.ALWAYS); + row.getChildren().addAll(keyLabel, valueNode); return row; } + private Node createPreloadToggle(AssetWorkspaceAssetDetails details) { + final boolean currentValue = details.summary().preload(); + final CheckBox checkBox = new CheckBox(yesNo(currentValue)); + checkBox.setSelected(currentValue); + checkBox.setFocusTraversable(false); + checkBox.getStyleClass().add("assets-details-readonly-check"); + checkBox.selectedProperty().addListener((ignored, previous, selected) -> checkBox.setText(yesNo(selected))); + checkBox.setOnAction(event -> updatePreload(details, checkBox.isSelected(), checkBox)); + return checkBox; + } + + private void updatePreload(AssetWorkspaceAssetDetails details, boolean preloadEnabled, CheckBox checkBox) { + checkBox.setDisable(true); + setInlineProgress("Updating preload...", ProgressBar.INDETERMINATE_PROGRESS, true); + appendLog("Updating preload for " + details.summary().assetName() + " to " + yesNo(preloadEnabled) + "."); + CompletableFuture + .runAsync(() -> writePreloadFlag(details.summary().assetRoot(), preloadEnabled)) + .whenComplete((ignored, throwable) -> Platform.runLater(() -> { + checkBox.setDisable(false); + if (throwable != null) { + final boolean previousValue = !preloadEnabled; + checkBox.setSelected(previousValue); + checkBox.setText(yesNo(previousValue)); + setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); + appendLog("Preload update failed: " + rootCauseMessage(throwable)); + return; + } + + final AssetWorkspaceAssetSummary updatedSummary = withPreload(details.summary(), preloadEnabled); + selectedAssetDetails = new AssetWorkspaceAssetDetails( + updatedSummary, + details.outputFormat(), + details.outputCodec(), + details.inputsByRole(), + details.diagnostics()); + state = AssetWorkspaceState.ready(replaceAssetSummary(updatedSummary), updatedSummary.selectionKey()); + setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); + appendLog("Preload updated for " + updatedSummary.assetName() + "."); + renderNavigator(); + renderDetails(); + })); + } + + private void writePreloadFlag(Path assetRoot, boolean preloadEnabled) { + final Path manifestPath = assetRoot.resolve("asset.json"); + try { + final JsonNode rootNode = MAPPER.readTree(manifestPath.toFile()); + if (!(rootNode instanceof ObjectNode rootObject)) { + throw new IllegalStateException("asset.json root is not an object"); + } + final JsonNode preloadNode = rootObject.path("preload"); + final ObjectNode preloadObject = preloadNode instanceof ObjectNode objectNode + ? objectNode + : rootObject.putObject("preload"); + preloadObject.put("enabled", preloadEnabled); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), rootObject); + } catch (IOException ioException) { + throw new IllegalStateException("Unable to update preload flag: " + ioException.getMessage(), ioException); + } + } + + private AssetWorkspaceAssetSummary withPreload(AssetWorkspaceAssetSummary summary, boolean preloadEnabled) { + return new AssetWorkspaceAssetSummary( + summary.selectionKey(), + summary.assetName(), + summary.state(), + summary.buildParticipation(), + summary.assetId(), + summary.assetFamily(), + summary.assetRoot(), + preloadEnabled, + summary.hasDiagnostics()); + } + + private List replaceAssetSummary(AssetWorkspaceAssetSummary updatedSummary) { + return state.assets().stream() + .map(asset -> asset.selectionKey().equals(updatedSummary.selectionKey()) ? updatedSummary : asset) + .toList(); + } + private void renderNavigatorProjection(AssetNavigatorProjection projection) { navigatorContent.getChildren().clear(); for (AssetNavigatorGroup group : projection.groups()) { @@ -716,44 +896,64 @@ public final class AssetWorkspace implements Workspace { private Node createAssetRow(AssetWorkspaceAssetSummary asset) { final VBox row = new VBox(4); row.getStyleClass().add("assets-workspace-asset-row"); - if (asset.selectionKey().equals(state.selectedKey())) { - row.getStyleClass().add("assets-workspace-asset-row-selected"); - } + row.getStyleClass().add(assetRowToneClass(asset.assetFamily())); + updateAssetRowSelection(row, asset.selectionKey().equals(state.selectedKey())); final HBox topLine = new HBox(8); topLine.setAlignment(Pos.CENTER_LEFT); - final Label icon = new Label(assetIcon(asset)); - icon.getStyleClass().add("assets-workspace-asset-icon"); final Label name = new Label(asset.assetName()); name.getStyleClass().add("assets-workspace-asset-name"); + name.getStyleClass().add(assetNameToneClass(asset.assetFamily())); final Region spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); - final FlowPane badges = new FlowPane(); - badges.setHgap(6); - badges.setVgap(4); - badges.getChildren().add(createBadge( - asset.state() == AssetWorkspaceAssetState.MANAGED - ? Container.i18n().text(I18n.ASSETS_BADGE_MANAGED) - : Container.i18n().text(I18n.ASSETS_BADGE_ORPHAN), - asset.state() == AssetWorkspaceAssetState.MANAGED - ? "assets-workspace-badge-managed" - : "assets-workspace-badge-orphan")); - badges.getChildren().add(createBadge(asset.assetFamily(), "assets-workspace-badge-family")); - if (asset.preload()) { - badges.getChildren().add(createBadge(Container.i18n().text(I18n.ASSETS_BADGE_PRELOAD), "assets-workspace-badge-preload")); + final HBox badges = new HBox(6); + badges.setAlignment(Pos.CENTER_RIGHT); + badges.getStyleClass().add("assets-workspace-asset-badges"); + if (asset.state() == AssetWorkspaceAssetState.UNREGISTERED) { + badges.getChildren().add(createBadge( + Container.i18n().text(I18n.ASSETS_BADGE_UNREGISTERED), + "assets-workspace-badge-orphan")); + } else if (asset.buildParticipation() == AssetWorkspaceBuildParticipation.INCLUDED) { + badges.getChildren().add(createBadge( + buildParticipationLabel(asset.buildParticipation()), + "assets-workspace-badge-preload")); + if (asset.preload()) { + badges.getChildren().add(createBadge( + Container.i18n().text(I18n.ASSETS_BADGE_PRELOAD), + "assets-workspace-badge-preload")); + } + } else { + badges.getChildren().add(createBadge( + buildParticipationLabel(asset.buildParticipation()), + "assets-workspace-badge-diagnostics")); } if (asset.hasDiagnostics()) { badges.getChildren().add(createBadge(Container.i18n().text(I18n.ASSETS_BADGE_DIAGNOSTICS), "assets-workspace-badge-diagnostics")); } - topLine.getChildren().addAll(icon, name, spacer, badges); + topLine.getChildren().addAll(name, spacer, badges); final Label path = new Label(AssetNavigatorProjectionBuilder.relativeRoot(asset, projectRoot())); path.getStyleClass().add("assets-workspace-asset-path"); row.getChildren().addAll(topLine, path); + assetRowsBySelectionKey.put(asset.selectionKey(), row); row.setOnMouseClicked(event -> selectAsset(asset.selectionKey())); return row; } + private void updateNavigatorSelection() { + assetRowsBySelectionKey.forEach((selectionKey, row) -> updateAssetRowSelection(row, selectionKey.equals(state.selectedKey()))); + } + + private void updateAssetRowSelection(VBox row, boolean selected) { + if (selected) { + if (!row.getStyleClass().contains("assets-workspace-asset-row-selected")) { + row.getStyleClass().add("assets-workspace-asset-row-selected"); + } + return; + } + row.getStyleClass().remove("assets-workspace-asset-row-selected"); + } + private Node createBadge(String text, String styleClass) { final Label badge = new Label(text); badge.getStyleClass().add("assets-workspace-badge"); @@ -761,6 +961,20 @@ public final class AssetWorkspace implements Workspace { return badge; } + private String registrationLabel(AssetWorkspaceAssetState state) { + return switch (state) { + case REGISTERED -> Container.i18n().text(I18n.ASSETS_VALUE_REGISTERED); + case UNREGISTERED -> Container.i18n().text(I18n.ASSETS_VALUE_UNREGISTERED); + }; + } + + private String buildParticipationLabel(AssetWorkspaceBuildParticipation buildParticipation) { + return switch (buildParticipation) { + case INCLUDED -> Container.i18n().text(I18n.ASSETS_VALUE_INCLUDED); + case EXCLUDED -> Container.i18n().text(I18n.ASSETS_VALUE_EXCLUDED); + }; + } + private Node createNavigatorMessage(String text) { final Label label = new Label(text); label.getStyleClass().add("assets-workspace-empty-state"); @@ -772,30 +986,111 @@ public final class AssetWorkspace implements Workspace { state = state.withSelection(selectionKey); stagedMutationPreview = null; appendLog("Selected asset " + selectionKey.stableKey() + "."); - renderState(); + updateNavigatorSelection(); + renderDetails(); workspaceBus.publish(new StudioAssetsWorkspaceSelectionChangedEvent(projectReference, selectionKey)); loadSelectedAssetDetails(selectionKey); } private boolean supportsAction(AssetWorkspaceAction action) { - return action == AssetWorkspaceAction.ADOPT - || action == AssetWorkspaceAction.REGISTER - || action == AssetWorkspaceAction.QUARANTINE + return action == AssetWorkspaceAction.REGISTER + || action == AssetWorkspaceAction.INCLUDE_IN_BUILD + || action == AssetWorkspaceAction.EXCLUDE_FROM_BUILD || action == AssetWorkspaceAction.RELOCATE - || action == AssetWorkspaceAction.FORGET || action == AssetWorkspaceAction.REMOVE; } + private void runDoctor() { + setNavigatorActionsDisabled(true); + setInlineProgress("Running doctor...", ProgressBar.INDETERMINATE_PROGRESS, true); + appendLog("Doctor started."); + CompletableFuture + .supplyAsync(() -> doctorService.doctor(new PackerDoctorRequest(projectContext(), PackerDoctorMode.EXPANDED_WORKSPACE, true))) + .whenComplete((result, throwable) -> Platform.runLater(() -> { + setNavigatorActionsDisabled(false); + if (throwable != null) { + setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); + appendLog("Doctor failed: " + rootCauseMessage(throwable)); + return; + } + handleDoctorResult(result); + })); + } + + private void runPack() { + setNavigatorActionsDisabled(true); + setInlineProgress("Packing assets...", ProgressBar.INDETERMINATE_PROGRESS, true); + appendLog("Pack started."); + CompletableFuture + .supplyAsync(() -> packService.build(new PackerBuildRequest(projectContext(), false))) + .whenComplete((result, throwable) -> Platform.runLater(() -> { + setNavigatorActionsDisabled(false); + if (throwable != null) { + setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); + appendLog("Pack failed: " + rootCauseMessage(throwable)); + return; + } + handlePackResult(result); + })); + } + + private void handleDoctorResult(PackerDoctorResult result) { + setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); + appendLog(result.summary()); + if (!result.safeFixes().isEmpty()) { + result.safeFixes().forEach(fix -> appendLog("Safe fix: " + fix)); + } + if (!result.diagnostics().isEmpty()) { + appendLog("Doctor diagnostics: " + result.diagnostics().size() + "."); + } + refresh(); + } + + private void handlePackResult(PackerBuildResult result) { + setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); + appendLog(result.summary()); + appendLog("Assets archive: " + projectRelativePath(result.assetsArchive())); + if (!result.companionArtifacts().isEmpty()) { + result.companionArtifacts().keySet().stream().sorted().forEach(path -> appendLog("Artifact: " + path)); + } + } + + private void setNavigatorActionsDisabled(boolean disabled) { + addAssetButton.setDisable(disabled); + doctorButton.setDisable(disabled); + packButton.setDisable(disabled); + } + + private PackerProjectContext projectContext() { + return new PackerProjectContext(projectReference.name(), projectReference.rootPath()); + } + private void requestMutationPreview(AssetWorkspaceAction action) { final AssetWorkspaceAssetSummary selectedAsset = state.selectedAsset().orElse(null); if (selectedAsset == null) { return; } + if (action == AssetWorkspaceAction.RELOCATE) { + runRelocateFlow(selectedAsset); + return; + } + if (action == AssetWorkspaceAction.REGISTER) { + runRegisterFlow(selectedAsset); + return; + } + if (action == AssetWorkspaceAction.INCLUDE_IN_BUILD) { + runDirectMutationFlow(selectedAsset, action); + return; + } + if (action == AssetWorkspaceAction.EXCLUDE_FROM_BUILD || action == AssetWorkspaceAction.REMOVE) { + runModalMutationFlow(selectedAsset, action); + return; + } try { - stagedMutationPreview = mutationService.preview(projectReference, selectedAsset, action); + stagedMutationPreview = mutationService.preview(projectReference, selectedAsset, action, null); appendLog("Preview ready for " + actionLabel(action) + "."); renderState(); - Platform.runLater(() -> detailsScroll.setVvalue(1.0d)); + Platform.runLater(() -> detailsScroll.setVvalue(0.0d)); } catch (RuntimeException runtimeException) { final String message = rootCauseMessage(runtimeException); appendLog("Preview failed: " + message); @@ -805,6 +1100,64 @@ public final class AssetWorkspace implements Workspace { } } + private void runRegisterFlow(AssetWorkspaceAssetSummary selectedAsset) { + runDirectMutationFlow(selectedAsset, AssetWorkspaceAction.REGISTER); + } + + private void runDirectMutationFlow(AssetWorkspaceAssetSummary selectedAsset, AssetWorkspaceAction action) { + try { + final AssetWorkspaceMutationPreview preview = + mutationService.preview(projectReference, selectedAsset, action, null); + if (!preview.canApply()) { + stagedMutationPreview = preview; + appendLog(actionLabel(action) + " blocked."); + renderState(); + return; + } + mutationService.apply(projectReference, preview); + appendLog("Applied " + actionLabel(preview.action()) + "."); + stagedMutationPreview = null; + refresh(); + } catch (RuntimeException runtimeException) { + final String message = rootCauseMessage(runtimeException); + appendLog("Mutation failed: " + message); + workspaceBus.publish(new StudioAssetsMutationFailedEvent(projectReference, action, message)); + stagedMutationPreview = null; + renderState(); + } + } + + private void runRelocateFlow(AssetWorkspaceAssetSummary selectedAsset) { + if (root.getScene() == null || root.getScene().getWindow() == null) { + return; + } + AssetRelocationWizard.showAndWait(root.getScene().getWindow(), projectReference, selectedAsset, mutationService) + .ifPresent(preview -> { + appendLog("Applied " + actionLabel(preview.action()) + "."); + stagedMutationPreview = null; + refresh(); + }); + } + + private void runModalMutationFlow(AssetWorkspaceAssetSummary selectedAsset, AssetWorkspaceAction action) { + try { + final AssetWorkspaceMutationPreview preview = mutationService.preview(projectReference, selectedAsset, action, null); + if (!showMutationConfirmationModal(preview)) { + return; + } + mutationService.apply(projectReference, preview); + appendLog("Applied " + actionLabel(preview.action()) + "."); + stagedMutationPreview = null; + refresh(); + } catch (RuntimeException runtimeException) { + final String message = rootCauseMessage(runtimeException); + appendLog("Mutation failed: " + message); + workspaceBus.publish(new StudioAssetsMutationFailedEvent(projectReference, action, message)); + stagedMutationPreview = null; + renderState(); + } + } + private Node createStagedMutationPanel(AssetWorkspaceMutationPreview preview) { final VBox panel = new VBox(10); panel.getStyleClass().add("assets-mutation-panel"); @@ -835,13 +1188,13 @@ public final class AssetWorkspace implements Workspace { final HBox actions = new HBox(8); final Button cancel = new Button(Container.i18n().text(I18n.ASSETS_MUTATION_CANCEL)); - cancel.getStyleClass().add("assets-mutation-cancel"); + cancel.getStyleClass().addAll("studio-button", "studio-button-cancel"); cancel.setOnAction(event -> { stagedMutationPreview = null; renderState(); }); final Button apply = new Button(Container.i18n().text(I18n.ASSETS_MUTATION_APPLY)); - apply.getStyleClass().add("assets-mutation-apply"); + apply.getStyleClass().addAll("studio-button", "studio-button-primary"); apply.setDisable(!preview.canApply()); apply.setOnAction(event -> applyStagedMutation(preview)); actions.getChildren().addAll(cancel, apply); @@ -864,6 +1217,11 @@ public final class AssetWorkspace implements Workspace { box.getChildren().add(createKeyValueRow( Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), projectRelativePath(preview.asset().assetRoot()))); + if (preview.targetAssetRoot() != null) { + box.getChildren().add(createKeyValueRow( + Container.i18n().text(I18n.ASSETS_LABEL_TARGET_LOCATION), + projectRelativePath(preview.targetAssetRoot()))); + } return box; } @@ -894,13 +1252,55 @@ public final class AssetWorkspace implements Workspace { return box; } + private boolean showMutationConfirmationModal(AssetWorkspaceMutationPreview preview) { + if (root.getScene() == null || root.getScene().getWindow() == null) { + return false; + } + + final Dialog dialog = new Dialog<>(); + dialog.initOwner(root.getScene().getWindow()); + dialog.setTitle(Container.i18n().text(I18n.ASSETS_MUTATION_CONFIRM_TITLE)); + dialog.setHeaderText(Container.i18n().format(I18n.ASSETS_MUTATION_CONFIRM_HEADER, actionLabel(preview.action()))); + dialog.getDialogPane().getButtonTypes().addAll(ButtonType.CANCEL, ButtonType.OK); + + final VBox content = new VBox(12); + content.setPadding(new Insets(8, 0, 0, 0)); + content.getChildren().add(createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_AFFECTED_ASSET), + createAffectedAssetContent(preview))); + + final AssetWorkspaceMutationImpactViewModel impacts = AssetWorkspaceMutationImpactViewModel.from(preview); + content.getChildren().add(createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_REGISTRY_IMPACT), + createMutationChangesContent(impacts.registryChanges(), Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_REGISTRY_IMPACT)))); + content.getChildren().add(createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_WORKSPACE_IMPACT), + createMutationChangesContent(impacts.workspaceChanges(), Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_WORKSPACE_IMPACT)))); + content.getChildren().add(createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_BLOCKERS), + createMutationMessages(preview.blockers(), "assets-mutation-message-blocker", Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_BLOCKERS)))); + content.getChildren().add(createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_WARNINGS), + createMutationMessages(preview.warnings(), "assets-mutation-message-warning", Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_WARNINGS)))); + content.getChildren().add(createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_SAFE_FIXES), + createMutationMessages(preview.safeFixes(), "assets-mutation-message-safe-fix", Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_SAFE_FIXES)))); + + final ScrollPane scrollPane = new ScrollPane(content); + scrollPane.setFitToWidth(true); + scrollPane.setPrefViewportWidth(640); + scrollPane.setPrefViewportHeight(520); + dialog.getDialogPane().setContent(scrollPane); + + final Node okButton = dialog.getDialogPane().lookupButton(ButtonType.OK); + okButton.setDisable(!preview.canApply()); + return dialog.showAndWait().filter(ButtonType.OK::equals).isPresent(); + } + private void applyStagedMutation(AssetWorkspaceMutationPreview preview) { if (!preview.canApply()) { return; } - if (preview.highRisk() && !confirmHighRisk(preview)) { - return; - } try { mutationService.apply(projectReference, preview); appendLog("Applied " + actionLabel(preview.action()) + "."); @@ -914,14 +1314,6 @@ public final class AssetWorkspace implements Workspace { } } - private boolean confirmHighRisk(AssetWorkspaceMutationPreview preview) { - final Alert alert = new Alert(Alert.AlertType.CONFIRMATION); - alert.setTitle(Container.i18n().text(I18n.ASSETS_MUTATION_CONFIRM_TITLE)); - alert.setHeaderText(Container.i18n().format(I18n.ASSETS_MUTATION_CONFIRM_HEADER, actionLabel(preview.action()))); - alert.setContentText(Container.i18n().text(I18n.ASSETS_MUTATION_CONFIRM_BODY)); - return alert.showAndWait().filter(ButtonType.OK::equals).isPresent(); - } - private Path firstPreviewInput(AssetWorkspaceAssetDetails details) { return details.inputsByRole().values().stream() .flatMap(List::stream) @@ -980,18 +1372,29 @@ public final class AssetWorkspace implements Workspace { return value ? Container.i18n().text(I18n.ASSETS_VALUE_YES) : Container.i18n().text(I18n.ASSETS_VALUE_NO); } - private String assetIcon(AssetWorkspaceAssetSummary asset) { - final String family = asset.assetFamily().toLowerCase(); - if (family.contains("image")) { - return "🖼"; + private String assetRowToneClass(String assetFamily) { + return "assets-workspace-asset-row-tone-" + assetFamilyTone(assetFamily); + } + + private String assetNameToneClass(String assetFamily) { + return "assets-workspace-asset-name-tone-" + assetFamilyTone(assetFamily); + } + + private String assetFamilyTone(String assetFamily) { + final String family = assetFamily == null ? "" : assetFamily.toLowerCase(Locale.ROOT); + if (family.contains("image") || family.contains("sprite") || family.contains("tile")) { + return "image"; } - if (family.contains("sound") || family.contains("audio")) { - return "🔊"; + if (family.contains("sound") || family.contains("audio") || family.contains("music")) { + return "audio"; } - if (family.contains("palette")) { - return "🎨"; + if (family.contains("palette") || family.contains("color")) { + return "palette"; } - return "◈"; + if (family.contains("font") || family.contains("text") || family.contains("script")) { + return "text"; + } + return "generic"; } private Path assetsRoot() { diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAction.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAction.java index 786f26ff..c0eeb6d0 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAction.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAction.java @@ -1,12 +1,9 @@ package p.studio.workspaces.assets; public enum AssetWorkspaceAction { - DOCTOR, - BUILD, - ADOPT, REGISTER, - QUARANTINE, + INCLUDE_IN_BUILD, + EXCLUDE_FROM_BUILD, RELOCATE, - FORGET, REMOVE } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilder.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilder.java index 7cdb9514..1576c858 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilder.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilder.java @@ -10,18 +10,21 @@ public final class AssetWorkspaceActionSetBuilder { public static AssetWorkspaceActionSet forAsset(AssetWorkspaceAssetSummary summary) { Objects.requireNonNull(summary, "summary"); return switch (summary.state()) { - case MANAGED -> new AssetWorkspaceActionSet( - List.of(AssetWorkspaceAction.DOCTOR, AssetWorkspaceAction.BUILD), + case REGISTERED -> summary.buildParticipation() == AssetWorkspaceBuildParticipation.INCLUDED + ? new AssetWorkspaceActionSet( + List.of(), + List.of( + AssetWorkspaceAction.EXCLUDE_FROM_BUILD, + AssetWorkspaceAction.RELOCATE, + AssetWorkspaceAction.REMOVE)) + : new AssetWorkspaceActionSet( + List.of(AssetWorkspaceAction.INCLUDE_IN_BUILD), List.of( - AssetWorkspaceAction.FORGET, - AssetWorkspaceAction.QUARANTINE, AssetWorkspaceAction.RELOCATE, AssetWorkspaceAction.REMOVE)); - case ORPHAN -> new AssetWorkspaceActionSet( - List.of(AssetWorkspaceAction.ADOPT, AssetWorkspaceAction.REGISTER), - List.of( - AssetWorkspaceAction.QUARANTINE, - AssetWorkspaceAction.RELOCATE)); + case UNREGISTERED -> new AssetWorkspaceActionSet( + List.of(AssetWorkspaceAction.REGISTER), + List.of(AssetWorkspaceAction.RELOCATE)); }; } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetState.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetState.java index 7f31d269..118f901f 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetState.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetState.java @@ -1,6 +1,6 @@ package p.studio.workspaces.assets; public enum AssetWorkspaceAssetState { - MANAGED, - ORPHAN + REGISTERED, + UNREGISTERED } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetSummary.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetSummary.java index 58fc590f..d6dbd00a 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetSummary.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetSummary.java @@ -7,6 +7,7 @@ public record AssetWorkspaceAssetSummary( AssetWorkspaceSelectionKey selectionKey, String assetName, AssetWorkspaceAssetState state, + AssetWorkspaceBuildParticipation buildParticipation, Integer assetId, String assetFamily, Path assetRoot, @@ -17,13 +18,17 @@ public record AssetWorkspaceAssetSummary( Objects.requireNonNull(selectionKey, "selectionKey"); assetName = Objects.requireNonNull(assetName, "assetName").trim(); Objects.requireNonNull(state, "state"); + Objects.requireNonNull(buildParticipation, "buildParticipation"); assetFamily = Objects.requireNonNullElse(assetFamily, "unknown").trim(); assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); if (assetName.isBlank()) { throw new IllegalArgumentException("assetName must not be blank"); } - if (state == AssetWorkspaceAssetState.MANAGED && assetId == null) { - throw new IllegalArgumentException("managed asset must expose assetId"); + if (state == AssetWorkspaceAssetState.REGISTERED && assetId == null) { + throw new IllegalArgumentException("registered asset must expose assetId"); + } + if (state == AssetWorkspaceAssetState.UNREGISTERED && buildParticipation != AssetWorkspaceBuildParticipation.EXCLUDED) { + throw new IllegalArgumentException("unregistered asset must stay excluded from build participation"); } } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceBuildParticipation.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceBuildParticipation.java new file mode 100644 index 00000000..279c1d76 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceBuildParticipation.java @@ -0,0 +1,6 @@ +package p.studio.workspaces.assets; + +public enum AssetWorkspaceBuildParticipation { + INCLUDED, + EXCLUDED +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationService.java index 8f5700ac..7bf01994 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationService.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationService.java @@ -2,8 +2,21 @@ package p.studio.workspaces.assets; import p.studio.projects.ProjectReference; +import java.nio.file.Path; + public interface AssetWorkspaceMutationService { - AssetWorkspaceMutationPreview preview(ProjectReference projectReference, AssetWorkspaceAssetSummary asset, AssetWorkspaceAction action); + default AssetWorkspaceMutationPreview preview( + ProjectReference projectReference, + AssetWorkspaceAssetSummary asset, + AssetWorkspaceAction action) { + return preview(projectReference, asset, action, null); + } + + AssetWorkspaceMutationPreview preview( + ProjectReference projectReference, + AssetWorkspaceAssetSummary asset, + AssetWorkspaceAction action, + Path targetRoot); void apply(ProjectReference projectReference, AssetWorkspaceMutationPreview preview); } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationService.java index d5e245a3..4f86a97e 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationService.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationService.java @@ -15,11 +15,13 @@ import java.util.stream.Stream; public final class FileSystemAssetWorkspaceMutationService implements AssetWorkspaceMutationService { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final String PROMETEU_DIR = ".prometeu"; - private static final String QUARANTINE_DIR = "quarantine"; - private static final String RECOVERED_DIR = "recovered"; @Override - public AssetWorkspaceMutationPreview preview(ProjectReference projectReference, AssetWorkspaceAssetSummary asset, AssetWorkspaceAction action) { + public AssetWorkspaceMutationPreview preview( + ProjectReference projectReference, + AssetWorkspaceAssetSummary asset, + AssetWorkspaceAction action, + Path targetRoot) { Objects.requireNonNull(projectReference, "projectReference"); Objects.requireNonNull(asset, "asset"); Objects.requireNonNull(action, "action"); @@ -37,45 +39,59 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks } switch (action) { - case ADOPT, REGISTER -> { - if (asset.state() == AssetWorkspaceAssetState.MANAGED) { - blockers.add("Asset is already managed."); + case REGISTER -> { + if (asset.state() == AssetWorkspaceAssetState.REGISTERED) { + blockers.add("Asset is already registered."); } else { changes.add(new AssetWorkspaceMutationChange( AssetWorkspaceMutationChangeScope.REGISTRY, "ADD", relativeRoot)); if (asset.hasDiagnostics()) { - warnings.add("Asset currently reports diagnostics and will still be adopted."); + warnings.add("Asset currently reports diagnostics and will still be registered."); } } } - case QUARANTINE -> { - if (isInsideQuarantine(asset.assetRoot(), assetsRoot)) { - blockers.add("Asset is already inside quarantine."); + case INCLUDE_IN_BUILD -> { + if (asset.state() != AssetWorkspaceAssetState.REGISTERED) { + blockers.add("Only registered assets can be included in builds."); + } else if (asset.buildParticipation() == AssetWorkspaceBuildParticipation.INCLUDED) { + blockers.add("Asset is already included in builds."); } else { - targetAssetRoot = nextAvailablePath(quarantineRoot(assetsRoot), sanitizeSegment(asset.assetName())); - if (asset.state() == AssetWorkspaceAssetState.MANAGED) { - changes.add(new AssetWorkspaceMutationChange( - AssetWorkspaceMutationChangeScope.REGISTRY, - "REMOVE", - relativeRoot)); - warnings.add("Quarantining a managed asset removes it from the active registry."); - } changes.add(new AssetWorkspaceMutationChange( - AssetWorkspaceMutationChangeScope.WORKSPACE, - "MOVE", - relativeRoot + " -> " + relativeAssetRoot(targetAssetRoot, assetsRoot))); - warnings.add("Quarantine is explicit and reversible, but the asset will leave its current workspace location."); + AssetWorkspaceMutationChangeScope.REGISTRY, + "UPDATE", + relativeRoot)); + warnings.add("The asset will remain registered and return to the build set."); + } + } + case EXCLUDE_FROM_BUILD -> { + if (asset.state() != AssetWorkspaceAssetState.REGISTERED) { + blockers.add("Only registered assets can be excluded from builds."); + } else if (asset.buildParticipation() == AssetWorkspaceBuildParticipation.EXCLUDED) { + blockers.add("Asset is already excluded from builds."); + } else { + changes.add(new AssetWorkspaceMutationChange( + AssetWorkspaceMutationChangeScope.REGISTRY, + "UPDATE", + relativeRoot)); + warnings.add("The asset will remain registered but leave the build set."); } } case RELOCATE -> { - targetAssetRoot = relocationTarget(asset, assetsRoot); - final String targetRelativeRoot = relativeAssetRoot(targetAssetRoot, assetsRoot); - if (asset.assetRoot().equals(targetAssetRoot)) { - blockers.add("Asset is already at the planned relocation target."); + targetAssetRoot = targetRoot == null + ? relocationTarget(asset, assetsRoot) + : targetRoot.toAbsolutePath().normalize(); + final AssetRootValidationResult validation = AssetRelocationTargetValidator.validate( + projectReference, + asset.assetRoot(), + targetAssetRoot); + if (!validation.valid()) { + blockers.add(validation.message()); } else { - if (asset.state() == AssetWorkspaceAssetState.MANAGED) { + targetAssetRoot = validation.assetRoot(); + final String targetRelativeRoot = validation.normalizedRelativeRoot(); + if (asset.state() == AssetWorkspaceAssetState.REGISTERED) { changes.add(new AssetWorkspaceMutationChange( AssetWorkspaceMutationChangeScope.REGISTRY, "UPDATE", @@ -88,19 +104,8 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks warnings.add("Relocation preserves asset identity, but it changes the root path seen by the workspace."); } } - case FORGET -> { - if (asset.state() != AssetWorkspaceAssetState.MANAGED) { - blockers.add("Only managed assets can be forgotten."); - } else { - changes.add(new AssetWorkspaceMutationChange( - AssetWorkspaceMutationChangeScope.REGISTRY, - "REMOVE", - relativeRoot)); - warnings.add("The asset will leave the managed build set."); - } - } case REMOVE -> { - if (asset.state() == AssetWorkspaceAssetState.MANAGED) { + if (asset.state() == AssetWorkspaceAssetState.REGISTERED) { changes.add(new AssetWorkspaceMutationChange( AssetWorkspaceMutationChangeScope.REGISTRY, "REMOVE", @@ -112,7 +117,6 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks relativeRoot)); warnings.add("Physical files inside the asset root will be deleted."); } - case DOCTOR, BUILD -> safeFixes.add("This action is handled outside the staged mutation flow."); } final boolean highRisk = action == AssetWorkspaceAction.REMOVE || action == AssetWorkspaceAction.RELOCATE; @@ -132,7 +136,7 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks final Path assetRoot = preview.asset().assetRoot(); switch (preview.action()) { - case ADOPT, REGISTER -> { + case REGISTER -> { if (registryContainsRoot(registry, assetRoot, assetsRoot)) { return; } @@ -145,19 +149,17 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks registry.nextAssetId = entry.assetId + 1; writeRegistry(assetsRoot, registry); } - case FORGET -> { - registry.assets.removeIf(entry -> entry.root != null && assetsRoot.resolve(entry.root).toAbsolutePath().normalize().equals(assetRoot)); + case INCLUDE_IN_BUILD, EXCLUDE_FROM_BUILD -> { + final boolean includedInBuild = preview.action() == AssetWorkspaceAction.INCLUDE_IN_BUILD; + registry.assets.stream() + .filter(entry -> entry.root != null && assetsRoot.resolve(entry.root).toAbsolutePath().normalize().equals(assetRoot)) + .findFirst() + .ifPresent(entry -> entry.includedInBuild = includedInBuild); writeRegistry(assetsRoot, registry); } - case QUARANTINE -> { - final Path targetAssetRoot = requireTargetAssetRoot(preview); - registry.assets.removeIf(entry -> entry.root != null && assetsRoot.resolve(entry.root).toAbsolutePath().normalize().equals(assetRoot)); - writeRegistry(assetsRoot, registry); - moveAssetRoot(assetRoot, targetAssetRoot); - } case RELOCATE -> { final Path targetAssetRoot = requireTargetAssetRoot(preview); - if (preview.asset().state() == AssetWorkspaceAssetState.MANAGED) { + if (preview.asset().state() == AssetWorkspaceAssetState.REGISTERED) { registry.assets.stream() .filter(entry -> entry.root != null && assetsRoot.resolve(entry.root).toAbsolutePath().normalize().equals(assetRoot)) .findFirst() @@ -171,8 +173,6 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks writeRegistry(assetsRoot, registry); deleteRecursively(assetRoot); } - case DOCTOR, BUILD -> { - } } } @@ -223,34 +223,12 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks return assetsRoot.relativize(assetRoot.toAbsolutePath().normalize()).toString().replace('\\', '/'); } - private Path quarantineRoot(Path assetsRoot) { - return assetsRoot.resolve(PROMETEU_DIR).resolve(QUARANTINE_DIR).toAbsolutePath().normalize(); - } - - private boolean isInsideQuarantine(Path assetRoot, Path assetsRoot) { - return assetRoot.toAbsolutePath().normalize().startsWith(quarantineRoot(assetsRoot)); - } - private Path relocationTarget(AssetWorkspaceAssetSummary asset, Path assetsRoot) { final Path assetRoot = asset.assetRoot(); - if (isInsideQuarantine(assetRoot, assetsRoot)) { - return nextAvailablePath(assetsRoot.resolve(RECOVERED_DIR), sanitizeSegment(asset.assetName())); - } final Path siblingParent = assetRoot.getParent() == null ? assetsRoot : assetRoot.getParent(); return nextAvailableSibling(siblingParent, assetRoot.getFileName().toString() + "-relocated"); } - private Path nextAvailablePath(Path parent, String baseName) { - final Path normalizedParent = parent.toAbsolutePath().normalize(); - Path candidate = normalizedParent.resolve(baseName); - int index = 2; - while (Files.exists(candidate)) { - candidate = normalizedParent.resolve(baseName + "-" + index); - index += 1; - } - return candidate; - } - private Path nextAvailableSibling(Path parent, String baseName) { final Path normalizedParent = parent.toAbsolutePath().normalize(); Path candidate = normalizedParent.resolve(baseName); @@ -262,16 +240,6 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks return candidate; } - private String sanitizeSegment(String value) { - final String sanitized = value == null - ? "asset" - : value.trim() - .replaceAll("[^A-Za-z0-9._-]+", "-") - .replaceAll("-{2,}", "-") - .replaceAll("^[.-]+|[.-]+$", ""); - return sanitized == null || sanitized.isBlank() ? "asset" : sanitized; - } - private Path requireTargetAssetRoot(AssetWorkspaceMutationPreview preview) { if (preview.targetAssetRoot() == null) { throw new IllegalStateException("Mutation preview does not define a target asset root"); @@ -330,5 +298,8 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks @JsonProperty("root") public String root; + + @JsonProperty("included_in_build") + public boolean includedInBuild = true; } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceService.java index c2dfc25c..34cfeea1 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceService.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceService.java @@ -23,7 +23,7 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ return new AssetWorkspaceSnapshot(List.of()); } - final Map registryByRoot = readRegistry(assetsRoot); + final Map registryByRoot = readRegistry(assetsRoot); try (Stream paths = Files.find(assetsRoot, Integer.MAX_VALUE, (path, attrs) -> attrs.isRegularFile() && path.getFileName().toString().equals("asset.json"))) { final List assets = paths @@ -42,7 +42,7 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ public AssetWorkspaceAssetDetails loadAssetDetails(ProjectReference projectReference, AssetWorkspaceSelectionKey selectionKey) { final Path projectRoot = Objects.requireNonNull(projectReference, "projectReference").rootPath(); final Path assetsRoot = projectRoot.resolve("assets"); - final Map registryByRoot = readRegistry(assetsRoot); + final Map registryByRoot = readRegistry(assetsRoot); final Path assetRoot = resolveAssetRoot(selectionKey, assetsRoot, registryByRoot); final Path assetManifestPath = assetRoot.resolve("asset.json"); try { @@ -65,14 +65,15 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ } } - private AssetWorkspaceAssetSummary buildAssetSummary(Path assetManifestPath, Map registryByRoot) { + private AssetWorkspaceAssetSummary buildAssetSummary(Path assetManifestPath, Map registryByRoot) { final Path assetRoot = assetManifestPath.getParent().toAbsolutePath().normalize(); try { final AssetManifest manifest = MAPPER.readValue(assetManifestPath.toFile(), AssetManifest.class); - final Integer assetId = registryByRoot.get(assetRoot); + final RegistryEntry registryEntry = registryByRoot.get(assetRoot); + final Integer assetId = registryEntry == null ? null : registryEntry.assetId(); final AssetWorkspaceAssetState state = assetId == null - ? AssetWorkspaceAssetState.ORPHAN - : AssetWorkspaceAssetState.MANAGED; + ? AssetWorkspaceAssetState.UNREGISTERED + : AssetWorkspaceAssetState.REGISTERED; final AssetWorkspaceSelectionKey selectionKey = assetId == null ? new AssetWorkspaceSelectionKey.OrphanAsset(assetRoot) : new AssetWorkspaceSelectionKey.ManagedAsset(assetId); @@ -87,6 +88,9 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ selectionKey, assetName, state, + state == AssetWorkspaceAssetState.REGISTERED + ? (registryEntry.includedInBuild() ? AssetWorkspaceBuildParticipation.INCLUDED : AssetWorkspaceBuildParticipation.EXCLUDED) + : AssetWorkspaceBuildParticipation.EXCLUDED, assetId, assetFamily, assetRoot, @@ -97,7 +101,8 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ return new AssetWorkspaceAssetSummary( selectionKey, assetRoot.getFileName().toString(), - AssetWorkspaceAssetState.ORPHAN, + AssetWorkspaceAssetState.UNREGISTERED, + AssetWorkspaceBuildParticipation.EXCLUDED, null, "unknown", assetRoot, @@ -106,7 +111,7 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ } } - private Map readRegistry(Path assetsRoot) { + private Map readRegistry(Path assetsRoot) { final Path registryPath = assetsRoot.resolve(".prometeu").resolve("index.json"); if (!Files.isRegularFile(registryPath)) { return Map.of(); @@ -117,12 +122,12 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ return Map.of(); } - final Map registryByRoot = new HashMap<>(); + final Map registryByRoot = new HashMap<>(); for (RegistryEntry entry : registry.assets()) { if (entry == null || entry.root() == null || entry.root().isBlank() || entry.assetId() == null) { continue; } - registryByRoot.put(assetsRoot.resolve(entry.root()).toAbsolutePath().normalize(), entry.assetId()); + registryByRoot.put(assetsRoot.resolve(entry.root()).toAbsolutePath().normalize(), entry); } return Map.copyOf(registryByRoot); } catch (IOException ioException) { @@ -133,10 +138,10 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ private Path resolveAssetRoot( AssetWorkspaceSelectionKey selectionKey, Path assetsRoot, - Map registryByRoot) { + Map registryByRoot) { return switch (selectionKey) { case AssetWorkspaceSelectionKey.ManagedAsset managedAsset -> registryByRoot.entrySet().stream() - .filter(entry -> managedAsset.assetId() == entry.getValue()) + .filter(entry -> managedAsset.assetId() == entry.getValue().assetId()) .map(Map.Entry::getKey) .findFirst() .orElseThrow(() -> new IllegalArgumentException("managed asset root not found for assetId " + managedAsset.assetId())); @@ -188,12 +193,12 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ } @JsonIgnoreProperties(ignoreUnknown = true) - private record RegistryEntry(Integer assetId, String root) { + private record RegistryEntry(Integer assetId, String root, boolean includedInBuild) { private RegistryEntry( @com.fasterxml.jackson.annotation.JsonProperty("asset_id") Integer assetId, - @com.fasterxml.jackson.annotation.JsonProperty("root") String root) { - this.assetId = assetId; - this.root = root; + @com.fasterxml.jackson.annotation.JsonProperty("root") String root, + @com.fasterxml.jackson.annotation.JsonProperty("included_in_build") Boolean includedInBuild) { + this(assetId, root, includedInBuild == null || includedInBuild); } } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetCreationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetCreationService.java new file mode 100644 index 00000000..f5b600c4 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetCreationService.java @@ -0,0 +1,84 @@ +package p.studio.workspaces.assets; + +import com.fasterxml.jackson.databind.ObjectMapper; +import p.packer.api.PackerProjectContext; +import p.packer.api.workspace.InitWorkspaceRequest; +import p.packer.foundation.PackerRegistryState; +import p.packer.foundation.PackerWorkspaceFoundation; +import p.studio.projects.ProjectReference; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public final class PackerBackedAssetCreationService implements AssetCreationService { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String ASSET_MANIFEST = "asset.json"; + + private final PackerWorkspaceFoundation workspaceFoundation; + + public PackerBackedAssetCreationService() { + this(new PackerWorkspaceFoundation()); + } + + PackerBackedAssetCreationService(PackerWorkspaceFoundation workspaceFoundation) { + this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation"); + } + + @Override + public AssetCreationResult create(ProjectReference projectReference, AssetCreationRequest request) { + final ProjectReference reference = Objects.requireNonNull(projectReference, "projectReference"); + final AssetCreationRequest creation = Objects.requireNonNull(request, "request"); + final PackerProjectContext project = new PackerProjectContext(reference.name(), reference.rootPath()); + workspaceFoundation.initWorkspace(new InitWorkspaceRequest(project)); + + final AssetRootValidationResult rootValidation = AssetRootValidator.validate(reference, creation.relativeRoot()); + if (!rootValidation.valid()) { + throw new IllegalArgumentException(rootValidation.message()); + } + if (!AssetCreationCatalog.supports(creation.assetType(), creation.outputFormat(), creation.outputCodec())) { + throw new IllegalArgumentException("Selected asset type, output format, and codec combination is not supported."); + } + final Path assetRoot = rootValidation.assetRoot(); + final Path manifestPath = assetRoot.resolve(ASSET_MANIFEST); + final PackerRegistryState registry = workspaceFoundation.loadRegistry(project); + if (workspaceFoundation.lookup().findByRoot(project, registry, assetRoot).isPresent()) { + throw new IllegalArgumentException("Asset root is already registered in the project: " + rootValidation.normalizedRelativeRoot()); + } + if (Files.isRegularFile(manifestPath)) { + throw new IllegalArgumentException("asset.json already exists at: " + rootValidation.normalizedRelativeRoot()); + } + + try { + Files.createDirectories(assetRoot); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifestDocument(creation)); + } catch (IOException exception) { + throw new IllegalStateException("Unable to create asset files: " + exception.getMessage(), exception); + } + + final var entry = workspaceFoundation.allocateIdentity(project, registry, assetRoot); + workspaceFoundation.saveRegistry(project, workspaceFoundation.appendAllocatedEntry(registry, entry)); + return new AssetCreationResult(new AssetWorkspaceSelectionKey.ManagedAsset(entry.assetId()), assetRoot); + } + + private Map manifestDocument(AssetCreationRequest request) { + final Map root = new LinkedHashMap<>(); + root.put("schema_version", 1); + root.put("name", request.assetName()); + root.put("type", request.assetType()); + root.put("inputs", Map.of()); + + final Map output = new LinkedHashMap<>(); + output.put("format", request.outputFormat()); + output.put("codec", request.outputCodec()); + root.put("output", output); + + final Map preload = new LinkedHashMap<>(); + preload.put("enabled", request.preloadEnabled()); + root.put("preload", preload); + return root; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationService.java index 3a285bb0..c265191e 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationService.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationService.java @@ -48,15 +48,34 @@ public final class PackerBackedAssetWorkspaceMutationService implements AssetWor } @Override - public AssetWorkspaceMutationPreview preview(ProjectReference projectReference, AssetWorkspaceAssetSummary asset, AssetWorkspaceAction action) { + public AssetWorkspaceMutationPreview preview( + ProjectReference projectReference, + AssetWorkspaceAssetSummary asset, + AssetWorkspaceAction action, + Path targetRoot) { Objects.requireNonNull(projectReference, "projectReference"); Objects.requireNonNull(asset, "asset"); + final Path validatedTargetRoot = validateTargetRoot(projectReference, asset, action, targetRoot); + if (action == AssetWorkspaceAction.RELOCATE && targetRoot != null && validatedTargetRoot == null) { + final AssetRootValidationResult validation = AssetRelocationTargetValidator.validate(projectReference, asset.assetRoot(), targetRoot); + final AssetWorkspaceMutationPreview preview = new AssetWorkspaceMutationPreview( + action, + asset, + List.of(validation.message()), + List.of(), + List.of(), + List.of(), + true, + targetRoot); + eventBus.publish(new StudioAssetsMutationPreviewReadyEvent(projectReference, action, 1)); + return preview; + } final PackerMutationPreview packerPreview = packerMutationService.preview( new PackerMutationRequest( project(projectReference), mutationType(action), assetReference(projectReference, asset), - null)); + validatedTargetRoot)); final AssetWorkspaceMutationPreview studioPreview = mapPreview(action, asset, packerPreview); final OperationSession session = new OperationSession(projectReference, action, studioPreview, packerPreview); previewSessions.put(studioPreview, session); @@ -135,13 +154,11 @@ public final class PackerBackedAssetWorkspaceMutationService implements AssetWor private PackerMutationType mutationType(AssetWorkspaceAction action) { return switch (Objects.requireNonNull(action, "action")) { - case ADOPT -> PackerMutationType.ADOPT_ASSET; case REGISTER -> PackerMutationType.REGISTER_ASSET; - case QUARANTINE -> PackerMutationType.QUARANTINE_ASSET; + case INCLUDE_IN_BUILD -> PackerMutationType.INCLUDE_ASSET_IN_BUILD; + case EXCLUDE_FROM_BUILD -> PackerMutationType.EXCLUDE_ASSET_FROM_BUILD; case RELOCATE -> PackerMutationType.RELOCATE_ASSET; - case FORGET -> PackerMutationType.FORGET_ASSET; case REMOVE -> PackerMutationType.REMOVE_ASSET; - case DOCTOR, BUILD -> throw new IllegalArgumentException("Action is not supported by the staged mutation flow: " + action); }; } @@ -161,6 +178,18 @@ public final class PackerBackedAssetWorkspaceMutationService implements AssetWor return event.affectedAssets().isEmpty() ? 1 : event.affectedAssets().size(); } + private Path validateTargetRoot( + ProjectReference projectReference, + AssetWorkspaceAssetSummary asset, + AssetWorkspaceAction action, + Path targetRoot) { + if (targetRoot == null || action != AssetWorkspaceAction.RELOCATE) { + return targetRoot; + } + final AssetRootValidationResult validation = AssetRelocationTargetValidator.validate(projectReference, asset.assetRoot(), targetRoot); + return validation.valid() ? validation.assetRoot() : null; + } + private record OperationSession( ProjectReference projectReference, AssetWorkspaceAction action, diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceService.java index a3782897..441eef2a 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceService.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceService.java @@ -1,6 +1,7 @@ package p.studio.workspaces.assets; import p.packer.api.PackerProjectContext; +import p.packer.api.assets.PackerBuildParticipation; import p.packer.api.assets.PackerAssetDetails; import p.packer.api.assets.PackerAssetState; import p.packer.api.assets.PackerAssetSummary; @@ -47,13 +48,14 @@ public final class PackerBackedAssetWorkspaceService implements AssetWorkspaceSe private AssetWorkspaceAssetSummary mapSummary(PackerAssetSummary summary) { final AssetWorkspaceAssetState state = toStudioState(summary); - final AssetWorkspaceSelectionKey selectionKey = state == AssetWorkspaceAssetState.MANAGED + final AssetWorkspaceSelectionKey selectionKey = state == AssetWorkspaceAssetState.REGISTERED ? new AssetWorkspaceSelectionKey.ManagedAsset(summary.identity().assetId()) : new AssetWorkspaceSelectionKey.OrphanAsset(summary.identity().assetRoot()); return new AssetWorkspaceAssetSummary( selectionKey, summary.identity().assetName(), state, + toBuildParticipation(summary.buildParticipation()), summary.identity().assetId(), summary.assetFamily(), summary.identity().assetRoot(), @@ -81,10 +83,17 @@ public final class PackerBackedAssetWorkspaceService implements AssetWorkspaceSe } private AssetWorkspaceAssetState toStudioState(PackerAssetSummary summary) { - if (summary.state() == PackerAssetState.MANAGED) { - return AssetWorkspaceAssetState.MANAGED; + if (summary.state() == PackerAssetState.REGISTERED) { + return AssetWorkspaceAssetState.REGISTERED; } - return summary.identity().assetId() != null ? AssetWorkspaceAssetState.MANAGED : AssetWorkspaceAssetState.ORPHAN; + return AssetWorkspaceAssetState.UNREGISTERED; + } + + private AssetWorkspaceBuildParticipation toBuildParticipation(PackerBuildParticipation buildParticipation) { + return switch (buildParticipation) { + case INCLUDED -> AssetWorkspaceBuildParticipation.INCLUDED; + case EXCLUDED -> AssetWorkspaceBuildParticipation.EXCLUDED; + }; } private PackerProjectContext project(ProjectReference projectReference) { diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/builder/BuilderWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/builder/BuilderWorkspace.java index bd5eac91..2e935a4c 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/builder/BuilderWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/builder/BuilderWorkspace.java @@ -53,6 +53,7 @@ public class BuilderWorkspace implements Workspace { private ToolBar buildToolBar() { buildButton.textProperty().bind(Container.i18n().bind(I18n.WORKSPACE_SHIPPER_BUTTON_RUN)); + buildButton.getStyleClass().addAll("studio-button", "studio-button-primary"); buildButton.setOnAction(e -> { logs.clear(); final var logAggregator = LogAggregator.with(logs::appendText); @@ -61,6 +62,7 @@ public class BuilderWorkspace implements Workspace { }); clearButton.textProperty().bind(Container.i18n().bind(I18n.WORKSPACE_SHIPPER_BUTTON_CLEAR)); + clearButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); clearButton.setOnAction(e -> logs.clear()); return new ToolBar(buildButton, clearButton); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorToolbar.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorToolbar.java index 9a244295..066a32c6 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorToolbar.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorToolbar.java @@ -17,7 +17,8 @@ public final class EditorToolbar extends HBox { Button saveBtn = iconButton("💾", "Save"); Button runBtn = iconButton("▶", "Run"); - runBtn.getStyleClass().add("accent"); + runBtn.getStyleClass().remove("studio-button-secondary"); + runBtn.getStyleClass().add("studio-button-primary"); Region spacer = new Region(); HBox.setHgrow(spacer, javafx.scene.layout.Priority.ALWAYS); @@ -34,7 +35,7 @@ public final class EditorToolbar extends HBox { private Button iconButton(String icon, String tooltip) { Button b = new Button(icon); b.setFocusTraversable(false); - b.getStyleClass().add("toolbar-button"); + b.getStyleClass().addAll("studio-button", "studio-button-secondary", "studio-button-icon"); return b; } } diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index 82aa3b60..78d247e3 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -69,19 +69,22 @@ workspace.shipper.button.clear=Clear workspace.assets=Assets assets.navigator.title=Asset Navigator +assets.navigator.action.add=Add Asset +assets.navigator.action.doctor=Doctor +assets.navigator.action.pack=Pack assets.details.title=Selected Asset assets.search.prompt=Search assets by name or path -assets.filter.managed=Managed -assets.filter.orphan=Orphan +assets.filter.registered=Registered +assets.filter.unregistered=Unregistered assets.filter.diagnostics=Diagnostics assets.filter.preload=Preload assets.state.loading=Loading assets... -assets.state.empty=No managed or orphan assets were found in this project. +assets.state.empty=No registered or unregistered assets were found in this project. assets.state.noResults=No assets match the current search or filters. assets.state.ready={0} visible assets ({1} total). assets.state.error=Asset workspace failed to load. -assets.badge.managed=Managed -assets.badge.orphan=Orphan +assets.badge.registered=Registered +assets.badge.unregistered=Unregistered assets.badge.preload=Preload assets.badge.diagnostics=Diagnostics assets.section.summary=Summary @@ -89,15 +92,10 @@ assets.section.runtimeContract=Runtime Contract assets.section.inputsPreview=Inputs / Preview assets.section.diagnostics=Diagnostics assets.section.actions=Actions -assets.actions.primary=Primary Actions -assets.actions.sensitive=Sensitive Actions -assets.action.doctor=Doctor -assets.action.build=Build -assets.action.adopt=Adopt assets.action.register=Register -assets.action.quarantine=Quarantine +assets.action.includeInBuild=Include In Build +assets.action.excludeFromBuild=Exclude From Build assets.action.relocate=Relocate -assets.action.forget=Forget assets.action.remove=Remove assets.mutation.previewTitle=Preview: {0} assets.mutation.section.changes=Changes @@ -115,17 +113,22 @@ assets.mutation.empty.warnings=No warnings. assets.mutation.empty.safeFixes=No safe fixes. assets.mutation.cancel=Cancel assets.mutation.apply=Apply -assets.mutation.confirm.title=Confirm High-Risk Mutation +assets.mutation.confirm.title=Confirm Mutation assets.mutation.confirm.header=Confirm {0} -assets.mutation.confirm.body=This mutation is marked as high risk and may change or delete workspace files. assets.label.name=Name -assets.label.state=State +assets.label.registration=Registration +assets.label.buildParticipation=Build Participation assets.label.assetId=Asset ID assets.label.type=Type assets.label.location=Location +assets.label.targetLocation=Target Location assets.label.format=Format assets.label.codec=Codec assets.label.preload=Preload +assets.value.registered=Registered +assets.value.unregistered=Unregistered +assets.value.included=Included +assets.value.excluded=Excluded assets.value.yes=Yes assets.value.no=No assets.progress.idle=Assets workspace idle. @@ -150,4 +153,47 @@ assets.details.loading=Waiting for asset data... assets.details.empty=Create or add assets to this project to start using the Assets workspace. assets.details.ready=Selected asset: {0}\nState: {1}\nRoot: {2} assets.details.noSelection=Select an asset from the navigator once assets are available. +assets.addWizard.title=Add Asset +assets.addWizard.description=Create a registered asset root through a guided flow. +assets.addWizard.step.root.title=Choose Asset Root +assets.addWizard.step.root.description=Pick a directory inside assets/. Existing roots that already contain asset.json cannot be reused. +assets.addWizard.step.details.title=Describe Asset Contract +assets.addWizard.step.details.description=Choose the asset family first. Output format and codec are unlocked progressively from that choice. +assets.addWizard.step.summary.title=Review and Confirm +assets.addWizard.step.summary.description=Confirm the registered asset you are about to create and choose whether it should preload at startup. +assets.addWizard.label.name=Asset Name +assets.addWizard.label.root=Asset Root +assets.addWizard.label.type=Asset Type +assets.addWizard.label.format=Output Format +assets.addWizard.label.codec=Output Codec +assets.addWizard.label.preload=Preload on startup +assets.addWizard.prompt.type=Choose asset type +assets.addWizard.prompt.format=Choose output format +assets.addWizard.prompt.codec=Choose output codec +assets.addWizard.assetsRootHint=Assets root: {0} +assets.addWizard.browse.title=Choose Asset Root Directory +assets.addWizard.note=This preload flag can be changed later by editing asset.json. Inputs start empty and can be added after creation. +assets.addWizard.button.create=Create Asset +assets.addWizard.error.name=Asset name is required. +assets.addWizard.error.root=Asset root is required. +assets.addWizard.error.type=Choose an asset type before continuing. +assets.addWizard.error.format=Choose an output format before continuing. +assets.addWizard.error.codec=Choose an output codec before continuing. +assets.addWizard.error.unsupportedCombination=The selected asset type, output format, and codec combination is not supported. +assets.relocateWizard.title=Relocate Asset +assets.relocateWizard.description=Choose the destination for this asset and confirm the relocation inside this modal. +assets.relocateWizard.step.destination.title=Choose Destination +assets.relocateWizard.step.destination.description=Pick the parent directory and the new folder name for this asset. +assets.relocateWizard.step.summary.title=Review Relocation +assets.relocateWizard.step.summary.description=Review the mutation impact before confirming this relocation. +assets.relocateWizard.label.currentRoot=Current Asset Root +assets.relocateWizard.label.destinationParent=Destination Parent +assets.relocateWizard.label.destinationName=Destination Folder Name +assets.relocateWizard.label.targetRoot=Planned Target Root +assets.relocateWizard.prompt.destinationName=Choose the new asset folder name +assets.relocateWizard.assetsRootHint=Destination must stay inside assets/: {0} +assets.relocateWizard.browse.title=Choose Destination Parent Directory +assets.relocateWizard.note=OK applies this relocation immediately. Use Back if you need to change the destination. +assets.relocateWizard.button.confirm=OK +assets.relocateWizard.button.preview=Preview Relocation workspace.debug=Debug diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index 1540c8a4..b14ab44f 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -26,21 +26,6 @@ -fx-border-width: 0 1 0 0; } -.studio-workspace-rail-button { - -fx-font-size: 16px; - -fx-background-color: transparent; - -fx-text-fill: #d4d4d4; - -fx-background-radius: 8; -} - -.studio-workspace-rail-button:hover { - -fx-background-color: #2a2d2e; -} - -.studio-workspace-rail-button:selected { - -fx-background-color: #37373d; -} - .studio-right-utility-panel { -fx-background-color: #1f1f1f; -fx-border-color: #2d2d2d; @@ -108,6 +93,113 @@ -fx-font-weight: bold; } +.studio-button { + -fx-background-radius: 10; + -fx-border-radius: 10; + -fx-border-width: 1; + -fx-padding: 8 12 8 12; + -fx-cursor: hand; +} + +.studio-button:disabled { + -fx-opacity: 0.45; +} + +.studio-button.studio-button-pill { + -fx-background-radius: 999; + -fx-border-radius: 999; +} + +.studio-button.studio-button-icon { + -fx-padding: 8; +} + +.studio-button.studio-button-primary { + -fx-background-color: #27507a; + -fx-border-color: #5ea0de; + -fx-text-fill: #f7fbff; +} + +.studio-button.studio-button-primary:hover { + -fx-background-color: #336698; + -fx-border-color: #8cc6f7; +} + +.studio-button.studio-button-secondary, +.studio-button.studio-button-cancel { + -fx-background-color: #1b2430; + -fx-border-color: #314154; + -fx-text-fill: #d6e0ea; +} + +.studio-button.studio-button-secondary:hover, +.studio-button.studio-button-cancel:hover { + -fx-background-color: #243243; + -fx-border-color: #556f8b; + -fx-text-fill: #eef6ff; +} + +.studio-button.studio-button-warning { + -fx-background-color: #4a3816; + -fx-border-color: #c79a3d; + -fx-text-fill: #ffe4a8; +} + +.studio-button.studio-button-warning:hover { + -fx-background-color: #5b461b; + -fx-border-color: #efbc59; +} + +.studio-button.studio-button-danger { + -fx-background-color: #3b191c; + -fx-border-color: #c7606a; + -fx-text-fill: #ffd6d9; +} + +.studio-button.studio-button-danger:hover { + -fx-background-color: #4b2025; + -fx-border-color: #f08791; +} + +.studio-button.studio-button-ghost { + -fx-background-color: transparent; + -fx-border-color: transparent; + -fx-text-fill: #d4d4d4; +} + +.studio-button.studio-button-ghost:hover { + -fx-background-color: #2a2d2e; + -fx-border-color: #3a414b; + -fx-text-fill: #f2f7fb; +} + +.studio-button.studio-button-toggle:selected, +.studio-button.studio-button-active { + -fx-background-color: #24415e; + -fx-border-color: #4d88bc; + -fx-text-fill: #ffffff; +} + +.studio-button.studio-button-toggle:selected:hover, +.studio-button.studio-button-active:hover { + -fx-background-color: #2c5174; + -fx-border-color: #79b1e1; +} + +.studio-button.studio-button-ghost.studio-button-toggle:selected { + -fx-background-color: #37373d; + -fx-border-color: transparent; + -fx-text-fill: #f7fbff; +} + +.studio-button.studio-button-ghost.studio-button-toggle:selected:hover { + -fx-background-color: #404047; +} + +.studio-workspace-rail-button { + -fx-font-size: 16px; +} + .studio-utility-placeholder { -fx-text-fill: #d4d4d4; -fx-padding: 16; @@ -190,6 +282,12 @@ -fx-accent: #5cb6ff; } +.assets-workspace-action-bar { + -fx-alignment: center-left; + -fx-spacing: 8; + -fx-padding: 0 0 0 8; +} + .assets-workspace-pane-title { -fx-text-fill: #f3f7fb; -fx-font-size: 15px; @@ -209,21 +307,6 @@ -fx-padding: 4 0 2 0; } -.assets-workspace-filter-button { - -fx-background-color: #12161d; - -fx-text-fill: #c7d5e5; - -fx-background-radius: 999; - -fx-border-radius: 999; - -fx-border-color: #2d3948; - -fx-cursor: hand; -} - -.assets-workspace-filter-button:selected { - -fx-background-color: #24415e; - -fx-border-color: #4d88bc; - -fx-text-fill: #ffffff; -} - .assets-workspace-summary { -fx-text-fill: #9ecbff; -fx-font-size: 12px; @@ -275,21 +358,91 @@ -fx-border-color: #4f8dc3; } -.assets-workspace-asset-icon { - -fx-font-size: 14px; +.assets-workspace-asset-row-selected:hover { + -fx-background-color: #1d2c3c; +} + +.assets-workspace-asset-row-tone-image { + -fx-background-color: #121a23; + -fx-border-color: #356187; +} + +.assets-workspace-asset-row-tone-image:hover { + -fx-background-color: #172433; +} + +.assets-workspace-asset-row-tone-audio { + -fx-background-color: #17131f; + -fx-border-color: #70529b; +} + +.assets-workspace-asset-row-tone-audio:hover { + -fx-background-color: #21192c; +} + +.assets-workspace-asset-row-tone-palette { + -fx-background-color: #1d1711; + -fx-border-color: #9c6d2b; +} + +.assets-workspace-asset-row-tone-palette:hover { + -fx-background-color: #281f15; +} + +.assets-workspace-asset-row-tone-text { + -fx-background-color: #122019; + -fx-border-color: #3e8763; +} + +.assets-workspace-asset-row-tone-text:hover { + -fx-background-color: #162a20; +} + +.assets-workspace-asset-row-tone-generic { + -fx-background-color: #11151b; + -fx-border-color: #334050; +} + +.assets-workspace-asset-row-tone-generic:hover { + -fx-background-color: #17202a; } .assets-workspace-asset-name { -fx-text-fill: #f6fbff; - -fx-font-size: 13px; + -fx-font-size: 15px; -fx-font-weight: bold; } +.assets-workspace-asset-name-tone-image { + -fx-text-fill: #9cd9ff; +} + +.assets-workspace-asset-name-tone-audio { + -fx-text-fill: #dcbfff; +} + +.assets-workspace-asset-name-tone-palette { + -fx-text-fill: #ffd69a; +} + +.assets-workspace-asset-name-tone-text { + -fx-text-fill: #aee8c5; +} + +.assets-workspace-asset-name-tone-generic { + -fx-text-fill: #f6fbff; +} + .assets-workspace-asset-path { -fx-text-fill: #9eacbb; -fx-font-size: 11px; } +.assets-workspace-asset-badges { + -fx-alignment: center-right; + -fx-padding: 0 4 0 10; +} + .assets-workspace-badge { -fx-font-size: 10px; -fx-padding: 3 7 3 7; @@ -298,24 +451,12 @@ -fx-border-width: 1; } -.assets-workspace-badge-managed { - -fx-background-color: #153425; - -fx-border-color: #2f8f59; - -fx-text-fill: #8ce3ae; -} - .assets-workspace-badge-orphan { -fx-background-color: #3a2d14; -fx-border-color: #bc8a31; -fx-text-fill: #ffd27a; } -.assets-workspace-badge-family { - -fx-background-color: #1c2733; - -fx-border-color: #38506a; - -fx-text-fill: #b7d8f8; -} - .assets-workspace-badge-preload { -fx-background-color: #271747; -fx-border-color: #7f65cf; @@ -348,6 +489,20 @@ -fx-padding: 4 0 4 0; } +.assets-details-summary-actions-row { + -fx-spacing: 12; + -fx-alignment: top-left; +} + +.assets-details-summary-actions-row > .assets-details-section:first-child { + -fx-min-width: 0; +} + +.assets-details-summary-actions-row > .assets-details-section:last-child { + -fx-pref-width: 280; + -fx-max-width: 320; +} + .assets-details-section { -fx-background-color: #11151b; -fx-background-radius: 12; @@ -385,6 +540,27 @@ -fx-font-size: 12px; } +.assets-details-readonly-check { + -fx-text-fill: #eef4fb; + -fx-font-size: 12px; +} + +.assets-details-readonly-check > .box { + -fx-background-color: #0f1318; + -fx-border-color: #3b4957; + -fx-border-radius: 4; + -fx-background-radius: 4; +} + +.assets-details-readonly-check:selected > .box { + -fx-background-color: #17314f; + -fx-border-color: #5cb6ff; +} + +.assets-details-readonly-check:selected > .box > .mark { + -fx-background-color: #eef7ff; +} + .assets-details-role-label { -fx-text-fill: #9fc3e7; -fx-font-size: 11px; @@ -392,19 +568,9 @@ } .assets-details-input-button { - -fx-background-color: #17202a; - -fx-text-fill: #e6eff8; - -fx-background-radius: 8; - -fx-border-radius: 8; - -fx-border-color: #2f4053; -fx-alignment: center-left; } -.assets-details-input-button-selected { - -fx-background-color: #224160; - -fx-border-color: #4f8dc3; -} - .assets-details-input-preview-split { -fx-background-color: transparent; } @@ -432,19 +598,6 @@ -fx-font-weight: bold; } -.assets-details-preview-zoom-button { - -fx-background-color: #17202a; - -fx-text-fill: #e6eff8; - -fx-background-radius: 999; - -fx-border-radius: 999; - -fx-border-color: #2f4053; -} - -.assets-details-preview-zoom-button:selected { - -fx-background-color: #224160; - -fx-border-color: #4f8dc3; -} - .assets-details-preview-text { -fx-control-inner-background: #10161d; -fx-text-fill: #e4edf6; @@ -484,25 +637,6 @@ -fx-font-size: 12px; } -.assets-details-action-button { - -fx-background-radius: 10; - -fx-border-radius: 10; - -fx-border-width: 1; - -fx-padding: 8 12 8 12; -} - -.assets-details-action-button-primary { - -fx-background-color: #173322; - -fx-border-color: #2f8f59; - -fx-text-fill: #c7f8d7; -} - -.assets-details-action-button-sensitive { - -fx-background-color: #3b191c; - -fx-border-color: #c7606a; - -fx-text-fill: #ffd6d9; -} - .assets-mutation-panel { -fx-background-color: #0f1318; -fx-background-radius: 12; @@ -543,22 +677,6 @@ -fx-font-size: 12px; } -.assets-mutation-cancel { - -fx-background-color: #1b2430; - -fx-text-fill: #d6e0ea; - -fx-border-color: #314154; - -fx-border-radius: 10; - -fx-background-radius: 10; -} - -.assets-mutation-apply { - -fx-background-color: #27507a; - -fx-text-fill: #f7fbff; - -fx-border-color: #5ea0de; - -fx-border-radius: 10; - -fx-background-radius: 10; -} - .assets-workspace-logs-pane { -fx-collapsible: true; } diff --git a/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java b/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java index 35cb5186..027c1c41 100644 --- a/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java +++ b/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java @@ -54,12 +54,12 @@ final class StudioActivityEventMapperTest { @Test void mapsMutationPreviewReadyToInfoEntry() { final StudioActivityEntry entry = StudioActivityEventMapper - .map(new StudioAssetsMutationPreviewReadyEvent(project(), AssetWorkspaceAction.QUARANTINE, 1)) + .map(new StudioAssetsMutationPreviewReadyEvent(project(), AssetWorkspaceAction.RELOCATE, 1)) .orElseThrow(); assertEquals("Assets", entry.source()); assertEquals(StudioActivityEntrySeverity.INFO, entry.severity()); - assertEquals("Preview ready: quarantine", entry.message()); + assertEquals("Preview ready: relocate", entry.message()); } @Test diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetCreationCatalogTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetCreationCatalogTest.java new file mode 100644 index 00000000..81d7b3cb --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetCreationCatalogTest.java @@ -0,0 +1,22 @@ +package p.studio.workspaces.assets; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class AssetCreationCatalogTest { + @Test + void outputFormatsDependOnAssetType() { + assertEquals(List.of("TILES/indexed_v1", "SPRITES/indexed_v1"), AssetCreationCatalog.outputFormatsFor("image_bank")); + assertEquals(List.of("AUDIO/pcm_v1"), AssetCreationCatalog.outputFormatsFor("sound_bank")); + } + + @Test + void outputCodecsDependOnFormat() { + assertEquals(List.of("RAW"), AssetCreationCatalog.outputCodecsFor("image_bank", "TILES/indexed_v1")); + assertTrue(AssetCreationCatalog.supports("palette_bank", "PALETTE/indexed_v1", "RAW")); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilderTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilderTest.java index 49f5660a..5099e215 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilderTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilderTest.java @@ -15,8 +15,8 @@ final class AssetNavigatorProjectionBuilderTest { final Path assetsRoot = projectRoot.resolve("assets"); final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build( List.of( - managedAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false), - orphanAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)), + registeredAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false), + unregisteredAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)), projectRoot, "", EnumSet.noneOf(AssetNavigatorFilter.class)); @@ -26,16 +26,16 @@ final class AssetNavigatorProjectionBuilderTest { } @Test - void managedAndOrphanFiltersBehaveAsStateFilterSet() { + void registeredAndUnregisteredFiltersBehaveAsStateFilterSet() { final Path projectRoot = Path.of("/tmp/project"); final Path assetsRoot = projectRoot.resolve("assets"); final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build( List.of( - managedAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false), - orphanAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)), + registeredAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false), + unregisteredAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)), projectRoot, "", - EnumSet.of(AssetNavigatorFilter.MANAGED)); + EnumSet.of(AssetNavigatorFilter.REGISTERED)); assertEquals(1, projection.visibleAssetCount()); assertEquals("ui_atlas", projection.groups().getFirst().assets().getFirst().assetName()); @@ -47,12 +47,12 @@ final class AssetNavigatorProjectionBuilderTest { final Path assetsRoot = projectRoot.resolve("assets"); final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build( List.of( - managedAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, true), - managedAsset(2, "bg_tiles", "image_bank", assetsRoot.resolve("bg/tiles"), true, false), - managedAsset(3, "voice_bank", "sound_bank", assetsRoot.resolve("audio/voice"), false, true)), + registeredAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, true), + registeredAsset(2, "bg_tiles", "image_bank", assetsRoot.resolve("bg/tiles"), true, false), + registeredAsset(3, "voice_bank", "sound_bank", assetsRoot.resolve("audio/voice"), false, true)), projectRoot, "", - EnumSet.of(AssetNavigatorFilter.MANAGED, AssetNavigatorFilter.PRELOAD, AssetNavigatorFilter.DIAGNOSTICS)); + EnumSet.of(AssetNavigatorFilter.REGISTERED, AssetNavigatorFilter.PRELOAD, AssetNavigatorFilter.DIAGNOSTICS)); assertEquals(1, projection.visibleAssetCount()); assertEquals("ui_atlas", projection.groups().getFirst().assets().getFirst().assetName()); @@ -63,8 +63,8 @@ final class AssetNavigatorProjectionBuilderTest { final Path projectRoot = Path.of("/tmp/project"); final Path assetsRoot = projectRoot.resolve("assets"); final List assets = List.of( - managedAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false), - orphanAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)); + registeredAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false), + unregisteredAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)); final AssetNavigatorProjection byName = AssetNavigatorProjectionBuilder.build( assets, @@ -83,7 +83,7 @@ final class AssetNavigatorProjectionBuilderTest { assertEquals("menu_sounds", byPath.groups().getFirst().assets().getFirst().assetName()); } - private AssetWorkspaceAssetSummary managedAsset( + private AssetWorkspaceAssetSummary registeredAsset( int assetId, String name, String family, @@ -93,7 +93,8 @@ final class AssetNavigatorProjectionBuilderTest { return new AssetWorkspaceAssetSummary( new AssetWorkspaceSelectionKey.ManagedAsset(assetId), name, - AssetWorkspaceAssetState.MANAGED, + AssetWorkspaceAssetState.REGISTERED, + AssetWorkspaceBuildParticipation.INCLUDED, assetId, family, root, @@ -101,7 +102,7 @@ final class AssetNavigatorProjectionBuilderTest { hasDiagnostics); } - private AssetWorkspaceAssetSummary orphanAsset( + private AssetWorkspaceAssetSummary unregisteredAsset( String name, String family, Path root, @@ -110,7 +111,8 @@ final class AssetNavigatorProjectionBuilderTest { return new AssetWorkspaceAssetSummary( new AssetWorkspaceSelectionKey.OrphanAsset(root), name, - AssetWorkspaceAssetState.ORPHAN, + AssetWorkspaceAssetState.UNREGISTERED, + AssetWorkspaceBuildParticipation.EXCLUDED, null, family, root, diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilderTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilderTest.java index c4f5429e..b0da02d8 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilderTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilderTest.java @@ -9,40 +9,58 @@ import static org.junit.jupiter.api.Assertions.assertEquals; final class AssetWorkspaceActionSetBuilderTest { @Test - void managedAssetsExposeDoctorBuildAndSensitiveMutations() { + void includedRegisteredAssetsExposeOnlySensitiveAssetMutations() { final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(new AssetWorkspaceAssetSummary( new AssetWorkspaceSelectionKey.ManagedAsset(42), "ui_atlas", - AssetWorkspaceAssetState.MANAGED, + AssetWorkspaceAssetState.REGISTERED, + AssetWorkspaceBuildParticipation.INCLUDED, 42, "image_bank", Path.of("/tmp/assets/ui_atlas"), true, false)); - assertEquals(List.of(AssetWorkspaceAction.DOCTOR, AssetWorkspaceAction.BUILD), actionSet.primaryActions()); + assertEquals(List.of(), actionSet.primaryActions()); assertEquals( List.of( - AssetWorkspaceAction.FORGET, - AssetWorkspaceAction.QUARANTINE, + AssetWorkspaceAction.EXCLUDE_FROM_BUILD, AssetWorkspaceAction.RELOCATE, AssetWorkspaceAction.REMOVE), actionSet.sensitiveActions()); } @Test - void orphanAssetsExposeAdoptRegisterAndSensitiveRelocationFlows() { + void excludedRegisteredAssetsExposeIncludeInBuildAndSensitiveMutations() { + final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(new AssetWorkspaceAssetSummary( + new AssetWorkspaceSelectionKey.ManagedAsset(42), + "ui_atlas", + AssetWorkspaceAssetState.REGISTERED, + AssetWorkspaceBuildParticipation.EXCLUDED, + 42, + "image_bank", + Path.of("/tmp/assets/ui_atlas"), + true, + false)); + + assertEquals(List.of(AssetWorkspaceAction.INCLUDE_IN_BUILD), actionSet.primaryActions()); + assertEquals(List.of(AssetWorkspaceAction.RELOCATE, AssetWorkspaceAction.REMOVE), actionSet.sensitiveActions()); + } + + @Test + void unregisteredAssetsExposeRegisterAndSensitiveRelocationFlows() { final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(new AssetWorkspaceAssetSummary( new AssetWorkspaceSelectionKey.OrphanAsset(Path.of("/tmp/assets/ui_sounds")), "ui_sounds", - AssetWorkspaceAssetState.ORPHAN, + AssetWorkspaceAssetState.UNREGISTERED, + AssetWorkspaceBuildParticipation.EXCLUDED, null, "sound_bank", Path.of("/tmp/assets/ui_sounds"), false, false)); - assertEquals(List.of(AssetWorkspaceAction.ADOPT, AssetWorkspaceAction.REGISTER), actionSet.primaryActions()); - assertEquals(List.of(AssetWorkspaceAction.QUARANTINE, AssetWorkspaceAction.RELOCATE), actionSet.sensitiveActions()); + assertEquals(List.of(AssetWorkspaceAction.REGISTER), actionSet.primaryActions()); + assertEquals(List.of(AssetWorkspaceAction.RELOCATE), actionSet.sensitiveActions()); } } diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModelTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModelTest.java index 1be2e720..96137f09 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModelTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModelTest.java @@ -13,7 +13,8 @@ final class AssetWorkspaceMutationImpactViewModelTest { final AssetWorkspaceAssetSummary asset = new AssetWorkspaceAssetSummary( new AssetWorkspaceSelectionKey.ManagedAsset(1), "ui_atlas", - AssetWorkspaceAssetState.MANAGED, + AssetWorkspaceAssetState.REGISTERED, + AssetWorkspaceBuildParticipation.INCLUDED, 1, "image_bank", Path.of("/tmp/assets/ui/atlas"), diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceStateTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceStateTest.java index cbd4f3ad..039bb903 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceStateTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceStateTest.java @@ -9,7 +9,7 @@ import static org.junit.jupiter.api.Assertions.*; final class AssetWorkspaceStateTest { @Test - void preservesManagedSelectionByAssetIdAcrossRefresh() { + void preservesRegisteredSelectionByAssetIdAcrossRefresh() { final AssetWorkspaceSelectionKey.ManagedAsset selected = new AssetWorkspaceSelectionKey.ManagedAsset(42); final List assets = List.of( managedAsset(42, "ui_atlas", Path.of("/tmp/assets/ui_atlas")), @@ -24,17 +24,17 @@ final class AssetWorkspaceStateTest { } @Test - void preservesOrphanSelectionByAssetRootAcrossRefresh() { - final Path orphanRoot = Path.of("/tmp/assets/orphan_bank"); - final AssetWorkspaceSelectionKey.OrphanAsset selected = new AssetWorkspaceSelectionKey.OrphanAsset(orphanRoot); + void preservesUnregisteredSelectionByAssetRootAcrossRefresh() { + final Path unregisteredRoot = Path.of("/tmp/assets/orphan_bank"); + final AssetWorkspaceSelectionKey.OrphanAsset selected = new AssetWorkspaceSelectionKey.OrphanAsset(unregisteredRoot); final List assets = List.of( - orphanAsset("orphan_bank", orphanRoot), - orphanAsset("other_bank", Path.of("/tmp/assets/other_bank"))); + unregisteredAsset("orphan_bank", unregisteredRoot), + unregisteredAsset("other_bank", Path.of("/tmp/assets/other_bank"))); final AssetWorkspaceState state = AssetWorkspaceState.ready(assets, selected); assertEquals(selected, state.selectedKey()); - assertEquals(orphanRoot.toAbsolutePath().normalize(), state.selectedAsset().orElseThrow().assetRoot()); + assertEquals(unregisteredRoot.toAbsolutePath().normalize(), state.selectedAsset().orElseThrow().assetRoot()); } @Test @@ -62,7 +62,8 @@ final class AssetWorkspaceStateTest { return new AssetWorkspaceAssetSummary( new AssetWorkspaceSelectionKey.ManagedAsset(assetId), name, - AssetWorkspaceAssetState.MANAGED, + AssetWorkspaceAssetState.REGISTERED, + AssetWorkspaceBuildParticipation.INCLUDED, assetId, "image_bank", root, @@ -70,11 +71,12 @@ final class AssetWorkspaceStateTest { false); } - private AssetWorkspaceAssetSummary orphanAsset(String name, Path root) { + private AssetWorkspaceAssetSummary unregisteredAsset(String name, Path root) { return new AssetWorkspaceAssetSummary( new AssetWorkspaceSelectionKey.OrphanAsset(root), name, - AssetWorkspaceAssetState.ORPHAN, + AssetWorkspaceAssetState.UNREGISTERED, + AssetWorkspaceBuildParticipation.EXCLUDED, null, "image_bank", root, diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationServiceTest.java index 3e80530e..933a381d 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationServiceTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationServiceTest.java @@ -14,51 +14,42 @@ final class FileSystemAssetWorkspaceMutationServiceTest { Path tempDir; @Test - void previewQuarantineForManagedAssetShowsRegistryAndWorkspaceImpact() throws Exception { + void previewRelocateForManagedAssetIsHighRiskAndPreservesIdentityContract() throws Exception { final Path projectRoot = createManagedAssetProject(); final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot); + final Path customTarget = projectRoot.resolve("assets/ui/atlas-v2"); - final AssetWorkspaceMutationPreview preview = service.preview(project("Main", projectRoot), asset, AssetWorkspaceAction.QUARANTINE); + final AssetWorkspaceMutationPreview preview = service.preview( + project("Main", projectRoot), + asset, + AssetWorkspaceAction.RELOCATE, + customTarget); assertTrue(preview.canApply()); - assertFalse(preview.highRisk()); - assertNotNull(preview.targetAssetRoot()); - assertEquals(1, preview.changes().stream().filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.REGISTRY).count()); - assertEquals(1, preview.changes().stream().filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.WORKSPACE).count()); + assertTrue(preview.highRisk()); + assertEquals(customTarget.toAbsolutePath().normalize(), preview.targetAssetRoot()); + assertTrue(preview.changes().stream().anyMatch(change -> + change.scope() == AssetWorkspaceMutationChangeScope.REGISTRY && change.verb().equals("UPDATE"))); + assertTrue(preview.changes().stream().anyMatch(change -> + change.scope() == AssetWorkspaceMutationChangeScope.WORKSPACE && change.verb().equals("MOVE"))); } @Test - void applyQuarantineMovesAssetAndRemovesRegistryEntry() throws Exception { + void applyExcludeFromBuildPreservesRegistrationAndUpdatesRegistryState() throws Exception { final Path projectRoot = createManagedAssetProject(); final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot); final ProjectReference project = project("Main", projectRoot); - final AssetWorkspaceMutationPreview preview = service.preview(project, asset, AssetWorkspaceAction.QUARANTINE); + final AssetWorkspaceMutationPreview preview = service.preview(project, asset, AssetWorkspaceAction.EXCLUDE_FROM_BUILD, null); + assertTrue(preview.canApply()); + service.apply(project, preview); - assertFalse(Files.exists(asset.assetRoot())); - assertTrue(Files.isDirectory(preview.targetAssetRoot())); final String registryJson = Files.readString(projectRoot.resolve("assets/.prometeu/index.json")); - assertFalse(registryJson.contains("\"root\" : \"ui/atlas\"")); - } - - @Test - void previewRelocateForManagedAssetIsHighRiskAndPreservesIdentityContract() throws Exception { - final Path projectRoot = createManagedAssetProject(); - final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); - final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot); - - final AssetWorkspaceMutationPreview preview = service.preview(project("Main", projectRoot), asset, AssetWorkspaceAction.RELOCATE); - - assertTrue(preview.canApply()); - assertTrue(preview.highRisk()); - assertNotNull(preview.targetAssetRoot()); - assertTrue(preview.changes().stream().anyMatch(change -> - change.scope() == AssetWorkspaceMutationChangeScope.REGISTRY && change.verb().equals("UPDATE"))); - assertTrue(preview.changes().stream().anyMatch(change -> - change.scope() == AssetWorkspaceMutationChangeScope.WORKSPACE && change.verb().equals("MOVE"))); + assertTrue(registryJson.contains("\"asset_id\" : 1")); + assertTrue(registryJson.contains("\"included_in_build\" : false")); } @Test @@ -67,15 +58,33 @@ final class FileSystemAssetWorkspaceMutationServiceTest { final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot); final ProjectReference project = project("Main", projectRoot); + final Path customTarget = projectRoot.resolve("assets/reorganized/atlas"); - final AssetWorkspaceMutationPreview preview = service.preview(project, asset, AssetWorkspaceAction.RELOCATE); + final AssetWorkspaceMutationPreview preview = service.preview(project, asset, AssetWorkspaceAction.RELOCATE, customTarget); service.apply(project, preview); assertFalse(Files.exists(asset.assetRoot())); - assertTrue(Files.isDirectory(preview.targetAssetRoot())); + assertTrue(Files.isDirectory(customTarget)); final String registryJson = Files.readString(projectRoot.resolve("assets/.prometeu/index.json")); assertTrue(registryJson.contains("\"asset_id\" : 1")); - assertTrue(registryJson.contains(preview.targetAssetRoot().getFileName().toString())); + assertTrue(registryJson.contains("reorganized/atlas")); + } + + @Test + void previewRelocateWithExistingDestinationCreatesBlocker() throws Exception { + final Path projectRoot = createManagedAssetProject(); + final Path existingTarget = projectRoot.resolve("assets/ui/existing"); + Files.createDirectories(existingTarget); + final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); + + final AssetWorkspaceMutationPreview preview = service.preview( + project("Main", projectRoot), + managedAsset(projectRoot), + AssetWorkspaceAction.RELOCATE, + existingTarget); + + assertFalse(preview.canApply()); + assertTrue(preview.blockers().stream().anyMatch(message -> message.contains("already exists"))); } @Test @@ -85,14 +94,15 @@ final class FileSystemAssetWorkspaceMutationServiceTest { final AssetWorkspaceAssetSummary asset = new AssetWorkspaceAssetSummary( new AssetWorkspaceSelectionKey.OrphanAsset(projectRoot.resolve("assets/missing")), "missing_asset", - AssetWorkspaceAssetState.ORPHAN, + AssetWorkspaceAssetState.UNREGISTERED, + AssetWorkspaceBuildParticipation.EXCLUDED, null, "unknown", projectRoot.resolve("assets/missing"), false, false); - final AssetWorkspaceMutationPreview preview = service.preview(project("Main", projectRoot), asset, AssetWorkspaceAction.QUARANTINE); + final AssetWorkspaceMutationPreview preview = service.preview(project("Main", projectRoot), asset, AssetWorkspaceAction.RELOCATE); assertFalse(preview.canApply()); assertFalse(preview.blockers().isEmpty()); @@ -118,7 +128,8 @@ final class FileSystemAssetWorkspaceMutationServiceTest { { "asset_id": 1, "asset_uuid": "uuid-1", - "root": "ui/atlas" + "root": "ui/atlas", + "included_in_build": true } ] } @@ -130,7 +141,8 @@ final class FileSystemAssetWorkspaceMutationServiceTest { return new AssetWorkspaceAssetSummary( new AssetWorkspaceSelectionKey.ManagedAsset(1), "ui_atlas", - AssetWorkspaceAssetState.MANAGED, + AssetWorkspaceAssetState.REGISTERED, + AssetWorkspaceBuildParticipation.INCLUDED, 1, "image_bank", projectRoot.resolve("assets/ui/atlas"), diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceServiceTest.java index bb529bd2..8e53ba8f 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceServiceTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceServiceTest.java @@ -25,7 +25,7 @@ final class FileSystemAssetWorkspaceServiceTest { } @Test - void marksRegistryRootsAsManagedAndUnregisteredAnchorsAsOrphan() throws Exception { + void marksRegistryRootsAsRegisteredAndUnregisteredAnchorsAsExcluded() throws Exception { final Path projectRoot = tempDir.resolve("main"); final Path assetsRoot = projectRoot.resolve("assets"); final Path managedRoot = assetsRoot.resolve("ui").resolve("atlas"); @@ -66,21 +66,23 @@ final class FileSystemAssetWorkspaceServiceTest { final AssetWorkspaceSnapshot snapshot = service.loadWorkspace(project("Main", projectRoot)); assertEquals(2, snapshot.assets().size()); - final AssetWorkspaceAssetSummary managed = snapshot.assets().stream() + final AssetWorkspaceAssetSummary registered = snapshot.assets().stream() .filter(asset -> asset.assetName().equals("ui_atlas")) .findFirst() .orElseThrow(); - final AssetWorkspaceAssetSummary orphan = snapshot.assets().stream() + final AssetWorkspaceAssetSummary unregistered = snapshot.assets().stream() .filter(asset -> asset.assetName().equals("ui_sounds")) .findFirst() .orElseThrow(); - assertEquals(AssetWorkspaceAssetState.MANAGED, managed.state()); - assertEquals(1, managed.assetId()); - assertTrue(managed.preload()); + assertEquals(AssetWorkspaceAssetState.REGISTERED, registered.state()); + assertEquals(AssetWorkspaceBuildParticipation.INCLUDED, registered.buildParticipation()); + assertEquals(1, registered.assetId()); + assertTrue(registered.preload()); - assertEquals(AssetWorkspaceAssetState.ORPHAN, orphan.state()); - assertEquals(null, orphan.assetId()); + assertEquals(AssetWorkspaceAssetState.UNREGISTERED, unregistered.state()); + assertEquals(AssetWorkspaceBuildParticipation.EXCLUDED, unregistered.buildParticipation()); + assertEquals(null, unregistered.assetId()); } @Test diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetCreationServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetCreationServiceTest.java new file mode 100644 index 00000000..4f103d8b --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetCreationServiceTest.java @@ -0,0 +1,95 @@ +package p.studio.workspaces.assets; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.studio.projects.ProjectReference; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class PackerBackedAssetCreationServiceTest { + @TempDir + Path tempDir; + + @Test + void createWritesManifestAndRegistersManagedAsset() throws Exception { + final Path projectRoot = tempDir.resolve("main"); + Files.createDirectories(projectRoot.resolve("assets")); + final PackerBackedAssetCreationService service = new PackerBackedAssetCreationService(); + + final AssetCreationResult result = service.create(project(), new AssetCreationRequest( + "ui_atlas", + "ui/atlas", + "image_bank", + "TILES/indexed_v1", + "RAW", + true)); + + assertEquals(new AssetWorkspaceSelectionKey.ManagedAsset(1), result.selectionKey()); + assertTrue(Files.isDirectory(projectRoot.resolve("assets/ui/atlas"))); + final String manifest = Files.readString(projectRoot.resolve("assets/ui/atlas/asset.json")); + assertTrue(manifest.contains("\"name\" : \"ui_atlas\"")); + assertTrue(manifest.contains("\"type\" : \"image_bank\"")); + assertTrue(manifest.contains("\"format\" : \"TILES/indexed_v1\"")); + assertTrue(manifest.contains("\"codec\" : \"RAW\"")); + assertTrue(manifest.contains("\"inputs\" : { }")); + final String registry = Files.readString(projectRoot.resolve("assets/.prometeu/index.json")); + assertTrue(registry.contains("\"asset_id\" : 1")); + assertTrue(registry.contains("\"root\" : \"ui/atlas\"")); + } + + @Test + void createRejectsUntrustedRelativeRoot() throws Exception { + final Path projectRoot = tempDir.resolve("main"); + Files.createDirectories(projectRoot.resolve("assets")); + final PackerBackedAssetCreationService service = new PackerBackedAssetCreationService(); + + assertThrows(IllegalArgumentException.class, () -> service.create(project(), new AssetCreationRequest( + "ui_atlas", + "../escape", + "image_bank", + "TILES/indexed_v1", + "RAW", + true))); + } + + @Test + void createRejectsExistingAssetManifest() throws Exception { + final Path projectRoot = tempDir.resolve("main"); + final Path assetRoot = projectRoot.resolve("assets/ui/existing"); + Files.createDirectories(assetRoot); + Files.writeString(assetRoot.resolve("asset.json"), "{}"); + final PackerBackedAssetCreationService service = new PackerBackedAssetCreationService(); + + assertThrows(IllegalArgumentException.class, () -> service.create(project(), new AssetCreationRequest( + "ui_existing", + "ui/existing", + "image_bank", + "TILES/indexed_v1", + "RAW", + true))); + } + + @Test + void createRejectsUnsupportedTypeFormatCodecCombination() throws Exception { + final Path projectRoot = tempDir.resolve("main"); + Files.createDirectories(projectRoot.resolve("assets")); + final PackerBackedAssetCreationService service = new PackerBackedAssetCreationService(); + + assertThrows(IllegalArgumentException.class, () -> service.create(project(), new AssetCreationRequest( + "ui_atlas", + "ui/atlas", + "sound_bank", + "TILES/indexed_v1", + "RAW", + true))); + } + + private ProjectReference project() { + return new ProjectReference("Main", "1.0.0", "pbs", 1, tempDir.resolve("main")); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationServiceTest.java index e51e115f..33d9cce8 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationServiceTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationServiceTest.java @@ -37,7 +37,8 @@ final class PackerBackedAssetWorkspaceMutationServiceTest { final AssetWorkspaceMutationPreview preview = service.preview( project("Main", projectRoot), managedAsset(projectRoot), - AssetWorkspaceAction.QUARANTINE); + AssetWorkspaceAction.RELOCATE, + projectRoot.resolve("assets/reorganized/atlas")); assertEquals(1, previewEvents.size()); assertTrue(preview.canApply()); @@ -53,16 +54,19 @@ final class PackerBackedAssetWorkspaceMutationServiceTest { final List appliedEvents = new ArrayList<>(); globalBus.subscribe(StudioAssetsMutationAppliedEvent.class, appliedEvents::add); final PackerBackedAssetWorkspaceMutationService service = service(globalBus); + final Path customTarget = projectRoot.resolve("assets/reorganized/atlas"); final AssetWorkspaceMutationPreview preview = service.preview( project("Main", projectRoot), managedAsset(projectRoot), - AssetWorkspaceAction.RELOCATE); + AssetWorkspaceAction.RELOCATE, + customTarget); service.apply(project("Main", projectRoot), preview); assertEquals(1, appliedEvents.size()); assertEquals(AssetWorkspaceAction.RELOCATE, appliedEvents.getFirst().action()); - assertTrue(Files.isDirectory(preview.targetAssetRoot())); + assertEquals(customTarget.toAbsolutePath().normalize(), preview.targetAssetRoot()); + assertTrue(Files.isDirectory(customTarget)); } @Test @@ -72,10 +76,12 @@ final class PackerBackedAssetWorkspaceMutationServiceTest { final List failedEvents = new ArrayList<>(); globalBus.subscribe(StudioAssetsMutationFailedEvent.class, failedEvents::add); final PackerBackedAssetWorkspaceMutationService service = service(globalBus); + final Path customTarget = projectRoot.resolve("assets/reorganized/atlas"); final AssetWorkspaceMutationPreview preview = service.preview( project("Main", projectRoot), managedAsset(projectRoot), - AssetWorkspaceAction.RELOCATE); + AssetWorkspaceAction.RELOCATE, + customTarget); deleteRecursively(projectRoot.resolve("assets/ui/atlas")); @@ -85,6 +91,27 @@ final class PackerBackedAssetWorkspaceMutationServiceTest { assertFalse(failedEvents.getFirst().message().isBlank()); } + @Test + void previewRelocateWithExistingDestinationReturnsBlockerWithoutCallingPacker() throws Exception { + final Path projectRoot = createManagedAssetProject(); + final Path existingTarget = projectRoot.resolve("assets/ui/existing"); + Files.createDirectories(existingTarget); + final StudioEventBus globalBus = new StudioEventBus(); + final List previewEvents = new ArrayList<>(); + globalBus.subscribe(StudioAssetsMutationPreviewReadyEvent.class, previewEvents::add); + final PackerBackedAssetWorkspaceMutationService service = service(globalBus); + + final AssetWorkspaceMutationPreview preview = service.preview( + project("Main", projectRoot), + managedAsset(projectRoot), + AssetWorkspaceAction.RELOCATE, + existingTarget); + + assertEquals(1, previewEvents.size()); + assertFalse(preview.canApply()); + assertTrue(preview.blockers().stream().anyMatch(message -> message.contains("already exists"))); + } + private PackerBackedAssetWorkspaceMutationService service(StudioEventBus globalBus) { return new PackerBackedAssetWorkspaceMutationService( new StudioWorkspaceEventBus(WorkspaceId.ASSETS, globalBus), @@ -126,7 +153,8 @@ final class PackerBackedAssetWorkspaceMutationServiceTest { return new AssetWorkspaceAssetSummary( new AssetWorkspaceSelectionKey.ManagedAsset(1), "ui_atlas", - AssetWorkspaceAssetState.MANAGED, + AssetWorkspaceAssetState.REGISTERED, + AssetWorkspaceBuildParticipation.INCLUDED, 1, "image_bank", projectRoot.resolve("assets/ui/atlas"), diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceServiceTest.java index b7304361..40391e79 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceServiceTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceServiceTest.java @@ -60,8 +60,8 @@ final class PackerBackedAssetWorkspaceServiceTest { final AssetWorkspaceSnapshot snapshot = service.loadWorkspace(project("Main", projectRoot)); assertEquals(2, snapshot.assets().size()); - assertTrue(snapshot.assets().stream().anyMatch(asset -> asset.state() == AssetWorkspaceAssetState.MANAGED)); - assertTrue(snapshot.assets().stream().anyMatch(asset -> asset.state() == AssetWorkspaceAssetState.ORPHAN)); + assertTrue(snapshot.assets().stream().anyMatch(asset -> asset.state() == AssetWorkspaceAssetState.REGISTERED)); + assertTrue(snapshot.assets().stream().anyMatch(asset -> asset.state() == AssetWorkspaceAssetState.UNREGISTERED)); } @Test diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerStudioIntegrationTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerStudioIntegrationTest.java index d1db00e1..77ba2b0e 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerStudioIntegrationTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerStudioIntegrationTest.java @@ -69,7 +69,7 @@ final class PackerStudioIntegrationTest { assertEquals(2, snapshot.assets().size()); final AssetWorkspaceAssetSummary orphan = snapshot.assets().stream() - .filter(asset -> asset.state() == AssetWorkspaceAssetState.ORPHAN) + .filter(asset -> asset.state() == AssetWorkspaceAssetState.UNREGISTERED) .findFirst() .orElseThrow(); final AssetWorkspaceMutationPreview preview = mutationService.preview(project, orphan, AssetWorkspaceAction.REGISTER); diff --git a/test-projects/main/assets/.prometeu/index.json b/test-projects/main/assets/.prometeu/index.json index 35f05e71..0a41d40c 100644 --- a/test-projects/main/assets/.prometeu/index.json +++ b/test-projects/main/assets/.prometeu/index.json @@ -1,9 +1,20 @@ { "schema_version" : 1, - "next_asset_id" : 2, + "next_asset_id" : 9, "assets" : [ { - "asset_id" : 1, - "asset_uuid" : "67cd978d-cd61-4641-ba9e-98fe4bc039bd", - "root" : "ui/atlas-relocated" + "asset_id" : 3, + "asset_uuid" : "21953cb8-4101-4790-9e5e-d95f5fbc9b5a", + "root" : "ui/atlas2", + "included_in_build" : true + }, { + "asset_id" : 7, + "asset_uuid" : "62a81570-8f47-4612-9288-6060e6c9a2e2", + "root" : "ui/one-more-atlas", + "included_in_build" : true + }, { + "asset_id" : 8, + "asset_uuid" : "9a7386e7-6f0e-4e4c-9919-0de71e0b7031", + "root" : "sound", + "included_in_build" : true } ] } \ No newline at end of file diff --git a/test-projects/main/assets/sound/asset.json b/test-projects/main/assets/sound/asset.json new file mode 100644 index 00000000..ba7990ac --- /dev/null +++ b/test-projects/main/assets/sound/asset.json @@ -0,0 +1,13 @@ +{ + "schema_version" : 1, + "name" : "bla", + "type" : "sound_bank", + "inputs" : { }, + "output" : { + "format" : "AUDIO/pcm_v1", + "codec" : "RAW" + }, + "preload" : { + "enabled" : true + } +} \ No newline at end of file diff --git a/test-projects/main/assets/ui/atlas-relocated/asset.json b/test-projects/main/assets/ui/atlas2/asset.json similarity index 100% rename from test-projects/main/assets/ui/atlas-relocated/asset.json rename to test-projects/main/assets/ui/atlas2/asset.json diff --git a/test-projects/main/assets/ui/atlas-relocated/sprites/confirm.png b/test-projects/main/assets/ui/atlas2/sprites/confirm.png similarity index 100% rename from test-projects/main/assets/ui/atlas-relocated/sprites/confirm.png rename to test-projects/main/assets/ui/atlas2/sprites/confirm.png diff --git a/test-projects/main/assets/ui/one-more-atlas/asset.json b/test-projects/main/assets/ui/one-more-atlas/asset.json new file mode 100644 index 00000000..5a0b7d8d --- /dev/null +++ b/test-projects/main/assets/ui/one-more-atlas/asset.json @@ -0,0 +1,13 @@ +{ + "schema_version" : 1, + "name" : "one-more-atlas", + "type" : "image_bank", + "inputs" : { }, + "output" : { + "format" : "TILES/indexed_v1", + "codec" : "RAW" + }, + "preload" : { + "enabled" : true + } +} \ No newline at end of file