added asset workspace working with packer

This commit is contained in:
bQUARKz 2026-03-12 06:24:57 +00:00
parent 1f6df50f09
commit ebbfe311ee
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
71 changed files with 2800 additions and 711 deletions

View File

@ -404,27 +404,20 @@ Variants:
* `add --dir` creates a dedicated asset root dir * `add --dir` creates a dedicated asset root dir
* `add --in-place` anchors next to the file * `add --in-place` anchors next to the file
### 13.3 `prometeu packer adopt` ### 13.3 `prometeu packer forget <name|id|uuid>`
Scans workspace for unregistered `asset.json` anchors and offers to register them.
* default: dry-run list
* `--apply` registers them
### 13.4 `prometeu packer forget <name|id|uuid>`
Removes an asset from the registry without deleting files. Removes an asset from the registry without deleting files.
Useful for WIP and cleanup. Useful for WIP and cleanup.
### 13.5 `prometeu packer rm <name|id|uuid> [--delete]` ### 13.4 `prometeu packer rm <name|id|uuid> [--delete]`
Removes the asset from the registry. Removes the asset from the registry.
* default: no deletion * default: no deletion
* `--delete` can remove the asset root dir (dangerous; must confirm in UI tooling, or require a force flag in CLI) * `--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: Lists managed assets:

View File

@ -26,7 +26,7 @@ There are two different truths for two different jobs:
- each `asset.json` tells the packer what that asset declares locally. - each `asset.json` tells the packer what that asset declares locally.
This split matters. 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`? ## 3. What is the difference between `asset_id` and `asset_name`?

View File

@ -14,7 +14,7 @@ Runtime-side reading semantics for `assets.pa` are defined upstream in `../runti
- project workspace with `assets/` - project workspace with `assets/`
- packer registry and control data under `assets/.prometeu/` - packer registry and control data under `assets/.prometeu/`
- managed asset roots anchored by `asset.json` - registered asset roots anchored by `asset.json`
## Core Rules ## Core Rules

View File

@ -1,7 +1,7 @@
# Workspace, Registry, and Asset Identity Specification # Workspace, Registry, and Asset Identity Specification
Status: Draft 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. Purpose: Define how the packer recognizes, tracks, and preserves asset identity.
## Authority and Precedence ## Authority and Precedence
@ -16,18 +16,22 @@ This specification consolidates the initial packer agenda and decision wave into
## Core Rules ## 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`. 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. 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 ## Identity Model
Each managed asset has: Each registered asset has:
- `asset_id`: stable project-local identity - `asset_id`: stable project-local identity
- `asset_uuid`: stable long-lived identity for migration/tooling scenarios - `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: The following are not primary identity:
@ -50,15 +54,26 @@ Rules:
Renaming `asset_name` is an API-visible change, but not an identity change. 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: Rules:
- it does not enter the build automatically; - it is excluded from the build automatically;
- it is diagnosable; - 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 ## Asset ID Allocation
@ -77,7 +92,7 @@ Identity-bearing conflicts are structural errors.
Examples: Examples:
- duplicate or ambiguous anchors under managed expectations; - duplicate or ambiguous anchors under registered expectations;
- manual copy that creates identity collision; - manual copy that creates identity collision;
- registered root missing anchor. - registered root missing anchor.
@ -91,6 +106,6 @@ Examples:
This specification is complete enough when: This specification is complete enough when:
- managed asset boundaries are unambiguous; - registered asset boundaries are unambiguous;
- registry authority is explicit; - registry authority is explicit;
- identity survives relocation without ambiguity. - identity survives relocation without ambiguity.

View File

@ -92,7 +92,7 @@ Rules:
## Preload ## Preload
Each managed asset must declare preload intent explicitly. Each registered asset must declare preload intent explicitly.
Baseline shape: Baseline shape:

View File

@ -74,7 +74,7 @@ The header carries:
Rules: 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 secondary runtime-only identity layer;
- no synthetic dense reindexing layer; - no synthetic dense reindexing layer;
- `asset_name` remains present as logical/API-facing metadata. - `asset_name` remains present as logical/API-facing metadata.

View File

@ -12,12 +12,12 @@ This specification consolidates the initial packer agenda and decision wave into
Diagnostics are divided into: Diagnostics are divided into:
- structural managed-world errors; - structural registered-world errors;
- advisory workspace hygiene diagnostics. - advisory workspace hygiene diagnostics.
Rules: Rules:
- structural managed-world errors block builds; - structural registered-world errors block builds;
- hygiene findings do not block baseline builds by default; - hygiene findings do not block baseline builds by default;
- workspace scanning is broader than build validation. - workspace scanning is broader than build validation.
@ -25,7 +25,7 @@ Rules:
Baseline doctor behavior: Baseline doctor behavior:
- managed-world validation by default; - registered-world validation by default;
- broader workspace hygiene scanning in expanded workspace mode; - broader workspace hygiene scanning in expanded workspace mode;
- safe mechanical fix application only for baseline fix flows. - safe mechanical fix application only for baseline fix flows.
@ -38,7 +38,7 @@ Rules:
- destructive or relocational mutations require explicit consent; - destructive or relocational mutations require explicit consent;
- quarantine is explicit and reversible; - quarantine is explicit and reversible;
- duplicate content detection is advisory, not structurally invalid by itself; - 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 ## Studio-First Service Surface
@ -48,8 +48,8 @@ Baseline core services:
- `init_workspace` - `init_workspace`
- `register_asset` - `register_asset`
- `adopt_asset` - `include_asset_in_build`
- `forget_asset` - `exclude_asset_from_build`
- `remove_asset` - `remove_asset`
- `list_assets` - `list_assets`
- `get_asset_details` - `get_asset_details`
@ -92,7 +92,7 @@ Rules:
- preview or analysis precedes broad mutation where appropriate; - preview or analysis precedes broad mutation where appropriate;
- safe-fix flows are modeled as services, not merely terminal flags; - 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 ## Structured Responses

View File

@ -45,8 +45,8 @@ The `Assets` workspace is a Studio view over packer services.
The workspace must assume: The workspace must assume:
- managed assets are the primary unit of identity and operation; - registered and unregistered assets may coexist inside `assets/`;
- orphan declarations may exist and must remain visible; - unregistered assets remain visible but excluded from builds;
- assets may aggregate many internal inputs; - assets may aggregate many internal inputs;
- runtime-facing output contract data exists for each asset; - runtime-facing output contract data exists for each asset;
- diagnostics, activity, progress, and staged mutation responses are available from Studio-facing services. - 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: 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; - where the asset root lives;
- which internal inputs belong to that asset; - which internal inputs belong to that asset;
- what the asset declares toward the runtime-facing contract; - 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`. - 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 expose strong visual semantics through icons, badges, and state styling.
- Asset nodes must surface: - Asset nodes must surface:
- managed/orphan state, - registration status,
- build participation,
- diagnostics presence, - diagnostics presence,
- preload intent, - preload intent,
- and broad asset family or type where available. - 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 ### Filters and Search
- The baseline filters are: - The baseline filters are:
- `Managed` - `Registered`
- `Orphan` - `Unregistered`
- `Diagnostics` - `Diagnostics`
- `Preload` - `Preload`
- The navigator must support textual search. - 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 - asset root path
- path context - 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 ### Selection Rules
- The baseline navigator is single-select. - The baseline navigator is single-select.
- Managed asset selection must be preserved by `asset_id`. - Registered asset selection must be preserved by `asset_id`.
- Orphan selection must be preserved by asset root path until the asset becomes managed. - 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 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. - 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. - 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 assets` state.
- The navigator must define explicit `no results` state. - The navigator must define explicit `no results` state.
- The navigator must define explicit `workspace error` state. - The navigator must define explicit `workspace error` state.
- Orphan assets must appear in the same navigator flow as managed assets. - Unregistered assets must appear in the same navigator flow as registered assets.
- Orphan styling must communicate `declared, not managed`, not `broken`. - Unregistered styling must communicate `present but not yet registered`, not `broken`.
### Inputs Expansion ### Inputs Expansion
@ -137,22 +149,22 @@ Filesystem structure may be visible and actionable as supporting context, but it
## Selected Asset Details Rules ## 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` 2. `Runtime Contract`
3. `Inputs / Preview` 3. `Inputs / Preview`
4. `Diagnostics` 4. `Diagnostics`
5. `Actions`
This section order is stable across asset families. This composition is stable across asset families.
### Summary ### Summary
- `Summary` must always be present for a selected asset. - `Summary` must always be present for a selected asset.
- `Summary` must show: - `Summary` must show:
- `asset_name` - `asset_name`
- managed/orphan state - registration status
- build participation
- `asset_id` when available - `asset_id` when available
- family/type - family/type
- asset root path - asset root path
@ -187,16 +199,21 @@ This section order is stable across asset families.
### Actions ### Actions
- Actions must be explicit. - 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. - Safe and routine actions must be visually separated from sensitive mutations.
- Hidden or automatic repair behavior is not allowed in the selected-asset view. - Hidden or automatic repair behavior is not allowed in the selected-asset view.
## Action Rules ## Action Rules
- Primary actions for managed assets include `Doctor` and `Build`. - Registered assets excluded from builds must expose an explicit `Include In Build` action.
- Primary actions for orphan assets include `Adopt`. - Primary actions for unregistered assets include `Register`.
- `Register` must remain available when explicit registration is the correct flow. - `Register` is a direct action in Studio UX. If registration is valid, clicking it must register immediately without an extra acknowledgement step.
- Sensitive actions such as `Forget`, `Remove`, and quarantine-like actions must be visually separated from routine actions. - 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. - 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 ## Activity, Progress, and Logs Rules
@ -230,24 +247,25 @@ This section order is stable across asset families.
Sensitive asset mutations are `preview-first`. 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 ### Mutations Requiring Preview
- `Adopt` - `Exclude From Build`
- `Forget`
- `Remove` - `Remove`
- `Quarantine`
- relocational changes such as move or rename of asset roots - relocational changes such as move or rename of asset roots
- batch operations - 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 Content
- Preview must show affected assets. - Preview must show affected assets.
- Relocation preview must show the planned target root chosen by the user.
- Preview must show proposed actions. - Preview must show proposed actions.
- Preview must distinguish registry impact from workspace impact. - Preview must distinguish registry impact from workspace impact.
- Preview must show blockers, warnings, and safe fixes as separate visual sections. - Preview must show blockers, warnings, and safe fixes as separate visual sections.

View File

@ -1,7 +1,6 @@
package p.packer.api.assets; package p.packer.api.assets;
public enum PackerAssetState { public enum PackerAssetState {
MANAGED, REGISTERED,
ORPHAN, UNREGISTERED
INVALID
} }

View File

@ -5,6 +5,7 @@ import java.util.Objects;
public record PackerAssetSummary( public record PackerAssetSummary(
PackerAssetIdentity identity, PackerAssetIdentity identity,
PackerAssetState state, PackerAssetState state,
PackerBuildParticipation buildParticipation,
String assetFamily, String assetFamily,
boolean preloadEnabled, boolean preloadEnabled,
boolean hasDiagnostics) { boolean hasDiagnostics) {
@ -12,9 +13,16 @@ public record PackerAssetSummary(
public PackerAssetSummary { public PackerAssetSummary {
Objects.requireNonNull(identity, "identity"); Objects.requireNonNull(identity, "identity");
Objects.requireNonNull(state, "state"); Objects.requireNonNull(state, "state");
Objects.requireNonNull(buildParticipation, "buildParticipation");
assetFamily = Objects.requireNonNullElse(assetFamily, "unknown").trim(); assetFamily = Objects.requireNonNullElse(assetFamily, "unknown").trim();
if (assetFamily.isBlank()) { if (assetFamily.isBlank()) {
assetFamily = "unknown"; 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");
}
} }
} }

View File

@ -0,0 +1,6 @@
package p.packer.api.assets;
public enum PackerBuildParticipation {
INCLUDED,
EXCLUDED
}

View File

@ -2,9 +2,8 @@ package p.packer.api.mutations;
public enum PackerMutationType { public enum PackerMutationType {
REGISTER_ASSET, REGISTER_ASSET,
ADOPT_ASSET, INCLUDE_ASSET_IN_BUILD,
FORGET_ASSET, EXCLUDE_ASSET_FROM_BUILD,
REMOVE_ASSET, REMOVE_ASSET,
QUARANTINE_ASSET,
RELOCATE_ASSET RELOCATE_ASSET
} }

View File

@ -2,6 +2,7 @@ package p.packer.building;
import p.packer.api.PackerOperationStatus; import p.packer.api.PackerOperationStatus;
import p.packer.api.PackerProjectContext; import p.packer.api.PackerProjectContext;
import p.packer.api.assets.PackerBuildParticipation;
import p.packer.api.assets.PackerAssetState; import p.packer.api.assets.PackerAssetState;
import p.packer.api.diagnostics.PackerDiagnostic; import p.packer.api.diagnostics.PackerDiagnostic;
import p.packer.api.diagnostics.PackerDiagnosticCategory; import p.packer.api.diagnostics.PackerDiagnosticCategory;
@ -45,16 +46,17 @@ public final class PackerBuildPlanner {
final List<PackerPlannedAsset> plannedAssets = new ArrayList<>(); final List<PackerPlannedAsset> plannedAssets = new ArrayList<>();
snapshot.assets().stream() snapshot.assets().stream()
.filter(asset -> asset.state() == PackerAssetState.MANAGED) .filter(asset -> asset.buildParticipation() == PackerBuildParticipation.INCLUDED)
.sorted(Comparator.comparingInt(asset -> asset.identity().assetId())) .sorted(Comparator.comparingInt(asset -> asset.identity().assetId()))
.forEach(asset -> { .forEach(asset -> {
final var detailsResult = detailsService.getAssetDetails(new GetAssetDetailsRequest(project, Integer.toString(asset.identity().assetId()))); final var detailsResult = detailsService.getAssetDetails(new GetAssetDetailsRequest(project, Integer.toString(asset.identity().assetId())));
diagnostics.addAll(detailsResult.diagnostics()); 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( diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR, PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.BUILD, PackerDiagnosticCategory.BUILD,
"Managed asset is not build-eligible: " + asset.identity().assetName(), "Registered asset is not build-eligible: " + asset.identity().assetName(),
asset.identity().assetRoot(), asset.identity().assetRoot(),
true)); true));
return; return;
@ -115,7 +117,7 @@ public final class PackerBuildPlanner {
"inputs", plannedAssets.stream().map(this::cacheKeyView).toList()))); "inputs", plannedAssets.stream().map(this::cacheKeyView).toList())));
return new PackerBuildPlanResult( return new PackerBuildPlanResult(
diagnostics.isEmpty() ? PackerOperationStatus.SUCCESS : PackerOperationStatus.PARTIAL, 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), new PackerBuildPlan(cacheKey, plannedAssets, assetTableJson, preloadJson),
List.copyOf(diagnostics)); List.copyOf(diagnostics));
} }

View File

@ -6,6 +6,7 @@ import p.packer.api.assets.PackerAssetDetails;
import p.packer.api.assets.PackerAssetIdentity; import p.packer.api.assets.PackerAssetIdentity;
import p.packer.api.assets.PackerAssetState; import p.packer.api.assets.PackerAssetState;
import p.packer.api.assets.PackerAssetSummary; import p.packer.api.assets.PackerAssetSummary;
import p.packer.api.assets.PackerBuildParticipation;
import p.packer.api.diagnostics.PackerDiagnostic; import p.packer.api.diagnostics.PackerDiagnostic;
import p.packer.api.diagnostics.PackerDiagnosticCategory; import p.packer.api.diagnostics.PackerDiagnosticCategory;
import p.packer.api.diagnostics.PackerDiagnosticSeverity; import p.packer.api.diagnostics.PackerDiagnosticSeverity;
@ -65,13 +66,19 @@ public final class PackerAssetDetailsService {
} }
final PackerAssetDeclaration declaration = parsed.declaration(); final PackerAssetDeclaration declaration = parsed.declaration();
final PackerAssetState state = resolved.registryEntry().isPresent()
? PackerAssetState.REGISTERED
: PackerAssetState.UNREGISTERED;
final PackerAssetSummary summary = new PackerAssetSummary( final PackerAssetSummary summary = new PackerAssetSummary(
new PackerAssetIdentity( new PackerAssetIdentity(
resolved.registryEntry().map(PackerRegistryEntry::assetId).orElse(null), resolved.registryEntry().map(PackerRegistryEntry::assetId).orElse(null),
resolved.registryEntry().map(PackerRegistryEntry::assetUuid).orElse(null), resolved.registryEntry().map(PackerRegistryEntry::assetUuid).orElse(null),
declaration.name(), declaration.name(),
resolved.assetRoot()), 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.type(),
declaration.preloadEnabled(), declaration.preloadEnabled(),
!diagnostics.isEmpty()); !diagnostics.isEmpty());
@ -89,13 +96,19 @@ public final class PackerAssetDetailsService {
} }
private GetAssetDetailsResult failureResult(ResolvedAssetReference resolved, List<PackerDiagnostic> diagnostics) { private GetAssetDetailsResult failureResult(ResolvedAssetReference resolved, List<PackerDiagnostic> diagnostics) {
final PackerAssetState state = resolved.registryEntry().isPresent()
? PackerAssetState.REGISTERED
: PackerAssetState.UNREGISTERED;
final PackerAssetSummary summary = new PackerAssetSummary( final PackerAssetSummary summary = new PackerAssetSummary(
new PackerAssetIdentity( new PackerAssetIdentity(
resolved.registryEntry().map(PackerRegistryEntry::assetId).orElse(null), resolved.registryEntry().map(PackerRegistryEntry::assetId).orElse(null),
resolved.registryEntry().map(PackerRegistryEntry::assetUuid).orElse(null), resolved.registryEntry().map(PackerRegistryEntry::assetUuid).orElse(null),
resolved.assetRoot().getFileName().toString(), resolved.assetRoot().getFileName().toString(),
resolved.assetRoot()), resolved.assetRoot()),
PackerAssetState.INVALID, state,
resolved.registryEntry().map(entry -> entry.includedInBuild()
? PackerBuildParticipation.INCLUDED
: PackerBuildParticipation.EXCLUDED).orElse(PackerBuildParticipation.EXCLUDED),
"unknown", "unknown",
false, false,
true); true);

View File

@ -86,38 +86,29 @@ public final class FileSystemPackerDoctorService implements PackerDoctorService
addDiagnostic(diagnostics, seenDiagnostics, diagnostic); 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( addDiagnostic(diagnostics, seenDiagnostics, new PackerDiagnostic(
PackerDiagnosticSeverity.WARNING, PackerDiagnosticSeverity.WARNING,
PackerDiagnosticCategory.HYGIENE, 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(), asset.identity().assetRoot(),
false)); 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())); 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 -> { details.inputsByRole().forEach((role, inputs) -> inputs.forEach(input -> {
if (Files.isRegularFile(input)) { if (Files.isRegularFile(input)) {
return; return;
} }
final boolean managed = asset.state() == PackerAssetState.MANAGED; final boolean registered = asset.state() == PackerAssetState.REGISTERED;
addDiagnostic(diagnostics, seenDiagnostics, new PackerDiagnostic( addDiagnostic(diagnostics, seenDiagnostics, new PackerDiagnostic(
managed ? PackerDiagnosticSeverity.ERROR : PackerDiagnosticSeverity.WARNING, registered ? PackerDiagnosticSeverity.ERROR : PackerDiagnosticSeverity.WARNING,
managed ? PackerDiagnosticCategory.STRUCTURAL : PackerDiagnosticCategory.HYGIENE, registered ? PackerDiagnosticCategory.STRUCTURAL : PackerDiagnosticCategory.HYGIENE,
"Declared input is missing for role '" + role + "': " + relativeEvidence(project, input), "Declared input is missing for role '" + role + "': " + relativeEvidence(project, input),
input, input,
managed)); registered));
})); }));
events.emit( events.emit(
PackerEventKind.PROGRESS_UPDATED, PackerEventKind.PROGRESS_UPDATED,
@ -143,7 +134,7 @@ public final class FileSystemPackerDoctorService implements PackerDoctorService
if (mode == PackerDoctorMode.EXPANDED_WORKSPACE) { if (mode == PackerDoctorMode.EXPANDED_WORKSPACE) {
return true; return true;
} }
return state == PackerAssetState.MANAGED || state == PackerAssetState.INVALID; return state == PackerAssetState.REGISTERED;
} }
private void addDiagnostic(List<PackerDiagnostic> diagnostics, Set<String> seenDiagnostics, PackerDiagnostic diagnostic) { private void addDiagnostic(List<PackerDiagnostic> diagnostics, Set<String> 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) { private String relativeAssetRoot(PackerProjectContext project, Path assetRoot) {
return project.rootPath().resolve("assets").toAbsolutePath().normalize() return project.rootPath().resolve("assets").toAbsolutePath().normalize()
.relativize(assetRoot.toAbsolutePath().normalize()) .relativize(assetRoot.toAbsolutePath().normalize())

View File

@ -37,7 +37,11 @@ public final class FileSystemPackerRegistryRepository implements PackerRegistryR
if (asset == null) { if (asset == null) {
continue; 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); validateEntries(entries);
@ -64,7 +68,11 @@ public final class FileSystemPackerRegistryRepository implements PackerRegistryR
document.schemaVersion = state.schemaVersion(); document.schemaVersion = state.schemaVersion();
document.nextAssetId = state.nextAssetId(); document.nextAssetId = state.nextAssetId();
document.assets = state.assets().stream() 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(); .toList();
MAPPER.writerWithDefaultPrettyPrinter().writeValue(registryPath.toFile(), document); MAPPER.writerWithDefaultPrettyPrinter().writeValue(registryPath.toFile(), document);
} catch (IOException exception) { } catch (IOException exception) {
@ -121,6 +129,7 @@ public final class FileSystemPackerRegistryRepository implements PackerRegistryR
private record RegistryAssetDocument( private record RegistryAssetDocument(
@JsonProperty("asset_id") int assetId, @JsonProperty("asset_id") int assetId,
@JsonProperty("asset_uuid") String assetUuid, @JsonProperty("asset_uuid") String assetUuid,
@JsonProperty("root") String root) { @JsonProperty("root") String root,
@JsonProperty("included_in_build") Boolean includedInBuild) {
} }
} }

View File

@ -5,7 +5,12 @@ import java.util.Objects;
public record PackerRegistryEntry( public record PackerRegistryEntry(
int assetId, int assetId,
String assetUuid, String assetUuid,
String root) { String root,
boolean includedInBuild) {
public PackerRegistryEntry(int assetId, String assetUuid, String root) {
this(assetId, assetUuid, root, true);
}
public PackerRegistryEntry { public PackerRegistryEntry {
assetUuid = Objects.requireNonNull(assetUuid, "assetUuid").trim(); assetUuid = Objects.requireNonNull(assetUuid, "assetUuid").trim();

View File

@ -36,9 +36,6 @@ import java.util.UUID;
import java.util.stream.Stream; import java.util.stream.Stream;
public final class FileSystemPackerMutationService implements PackerMutationService { 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 PackerWorkspaceFoundation workspaceFoundation;
private final PackerAssetDetailsService detailsService; private final PackerAssetDetailsService detailsService;
private final PackerProjectWriteCoordinator writeCoordinator; private final PackerProjectWriteCoordinator writeCoordinator;
@ -97,10 +94,10 @@ public final class FileSystemPackerMutationService implements PackerMutationServ
final Path assetRoot = context.assetDetails().summary().identity().assetRoot(); final Path assetRoot = context.assetDetails().summary().identity().assetRoot();
return switch (preview.request().type()) { return switch (preview.request().type()) {
case REGISTER_ASSET, ADOPT_ASSET -> applyRegister(project, registry, assetRoot, preview); case REGISTER_ASSET -> applyRegister(project, registry, assetRoot, preview);
case FORGET_ASSET -> applyForget(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 REMOVE_ASSET -> applyRemove(project, registry, assetRoot, preview);
case QUARANTINE_ASSET -> applyQuarantine(project, registry, assetRoot, preview);
case RELOCATE_ASSET -> applyRelocate(project, registry, assetRoot, preview); case RELOCATE_ASSET -> applyRelocate(project, registry, assetRoot, preview);
}; };
} }
@ -131,18 +128,26 @@ public final class FileSystemPackerMutationService implements PackerMutationServ
List.of()); List.of());
} }
private PackerMutationResult applyForget( private PackerMutationResult applyBuildParticipationChange(
PackerProjectContext project, PackerProjectContext project,
PackerRegistryState registry, PackerRegistryState registry,
Path assetRoot, Path assetRoot,
PackerMutationPreview preview) { PackerMutationPreview preview,
boolean includedInBuild) {
final PackerRegistryState updated = registry.withAssets( final PackerRegistryState updated = registry.withAssets(
registry.assets().stream() 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(), .toList(),
registry.nextAssetId()); registry.nextAssetId());
workspaceFoundation.saveRegistry(project, updated); 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( 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()); 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( private PackerMutationResult applyRelocate(
PackerProjectContext project, PackerProjectContext project,
PackerRegistryState registry, PackerRegistryState registry,
@ -184,7 +174,11 @@ public final class FileSystemPackerMutationService implements PackerMutationServ
final PackerRegistryState updated = registry.withAssets( final PackerRegistryState updated = registry.withAssets(
registry.assets().stream() registry.assets().stream()
.map(entry -> PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot) .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) : entry)
.toList(), .toList(),
registry.nextAssetId()); registry.nextAssetId());
@ -206,11 +200,11 @@ public final class FileSystemPackerMutationService implements PackerMutationServ
Path targetAssetRoot = null; Path targetAssetRoot = null;
switch (request.type()) { switch (request.type()) {
case REGISTER_ASSET, ADOPT_ASSET -> { case REGISTER_ASSET -> {
if (managed) { 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."); blockers.add("Asset declaration must be valid before registration.");
} }
if (!blockers.isEmpty()) { if (!blockers.isEmpty()) {
@ -221,12 +215,24 @@ public final class FileSystemPackerMutationService implements PackerMutationServ
warnings.add("Asset currently reports diagnostics and will still be registered."); warnings.add("Asset currently reports diagnostics and will still be registered.");
} }
} }
case FORGET_ASSET -> { case INCLUDE_ASSET_IN_BUILD -> {
if (!managed) { 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 { } else {
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "REMOVE", relativeRoot)); actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "UPDATE", relativeRoot));
warnings.add("The asset will leave the managed build set."); 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 -> { case REMOVE_ASSET -> {
@ -236,24 +242,6 @@ public final class FileSystemPackerMutationService implements PackerMutationServ
actions.add(new PackerProposedAction(PackerOperationClass.WORKSPACE_MUTATION, "DELETE", relativeRoot)); actions.add(new PackerProposedAction(PackerOperationClass.WORKSPACE_MUTATION, "DELETE", relativeRoot));
warnings.add("Physical files inside the asset root will be deleted."); 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 -> { case RELOCATE_ASSET -> {
targetAssetRoot = request.targetRoot() != null targetAssetRoot = request.targetRoot() != null
? request.targetRoot() ? request.targetRoot()
@ -291,26 +279,15 @@ public final class FileSystemPackerMutationService implements PackerMutationServ
final List<String> initialBlockers = assetDetails.diagnostics().stream() final List<String> initialBlockers = assetDetails.diagnostics().stream()
.filter(PackerDiagnostic::blocking) .filter(PackerDiagnostic::blocking)
.map(PackerDiagnostic::message) .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.REMOVE_ASSET
&& request.type() != PackerMutationType.QUARANTINE_ASSET
&& request.type() != PackerMutationType.RELOCATE_ASSET) && request.type() != PackerMutationType.RELOCATE_ASSET)
.toList(); .toList();
return new ResolvedMutationContext(request.project(), request, assetDetails, initialBlockers); 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) { 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(); final Path siblingParent = assetRoot.getParent() == null ? PackerWorkspacePaths.assetsRoot(project) : assetRoot.getParent();
return nextAvailablePath(siblingParent, assetRoot.getFileName().toString() + "-relocated"); return nextAvailablePath(siblingParent, assetRoot.getFileName().toString() + "-relocated");
} }

View File

@ -6,6 +6,7 @@ import p.packer.api.PackerProjectContext;
import p.packer.api.assets.PackerAssetIdentity; import p.packer.api.assets.PackerAssetIdentity;
import p.packer.api.assets.PackerAssetState; import p.packer.api.assets.PackerAssetState;
import p.packer.api.assets.PackerAssetSummary; import p.packer.api.assets.PackerAssetSummary;
import p.packer.api.assets.PackerBuildParticipation;
import p.packer.api.diagnostics.PackerDiagnostic; import p.packer.api.diagnostics.PackerDiagnostic;
import p.packer.api.diagnostics.PackerDiagnosticCategory; import p.packer.api.diagnostics.PackerDiagnosticCategory;
import p.packer.api.diagnostics.PackerDiagnosticSeverity; import p.packer.api.diagnostics.PackerDiagnosticSeverity;
@ -160,9 +161,12 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
: "unknown"; : "unknown";
final boolean preload = parsed.declaration() != null && parsed.declaration().preloadEnabled(); final boolean preload = parsed.declaration() != null && parsed.declaration().preloadEnabled();
final boolean hasDiagnostics = !parsed.diagnostics().isEmpty(); final boolean hasDiagnostics = !parsed.diagnostics().isEmpty();
final PackerAssetState state = parsed.valid() final PackerAssetState state = registryEntry == null
? (registryEntry == null ? PackerAssetState.ORPHAN : PackerAssetState.MANAGED) ? PackerAssetState.UNREGISTERED
: PackerAssetState.INVALID; : PackerAssetState.REGISTERED;
final PackerBuildParticipation buildParticipation = state == PackerAssetState.REGISTERED
? (registryEntry.includedInBuild() ? PackerBuildParticipation.INCLUDED : PackerBuildParticipation.EXCLUDED)
: PackerBuildParticipation.EXCLUDED;
return new PackerAssetSummary( return new PackerAssetSummary(
new PackerAssetIdentity( new PackerAssetIdentity(
registryEntry == null ? null : registryEntry.assetId(), registryEntry == null ? null : registryEntry.assetId(),
@ -170,6 +174,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
assetName, assetName,
assetRoot), assetRoot),
state, state,
buildParticipation,
assetFamily, assetFamily,
preload, preload,
hasDiagnostics); hasDiagnostics);

View File

@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import p.packer.api.PackerOperationStatus; import p.packer.api.PackerOperationStatus;
import p.packer.api.PackerProjectContext; import p.packer.api.PackerProjectContext;
import p.packer.api.assets.PackerBuildParticipation;
import p.packer.api.assets.PackerAssetState; import p.packer.api.assets.PackerAssetState;
import p.packer.api.workspace.GetAssetDetailsRequest; import p.packer.api.workspace.GetAssetDetailsRequest;
import p.packer.testing.PackerFixtureLocator; import p.packer.testing.PackerFixtureLocator;
@ -20,28 +21,30 @@ final class PackerAssetDetailsServiceTest {
Path tempDir; Path tempDir;
@Test @Test
void returnsManagedDetailsForRegisteredAssetReferenceById() throws Exception { void returnsRegisteredDetailsForRegisteredAssetReferenceById() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("managed")); final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("managed"));
final PackerAssetDetailsService service = new PackerAssetDetailsService(); final PackerAssetDetailsService service = new PackerAssetDetailsService();
final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), "1")); final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), "1"));
assertEquals(PackerOperationStatus.SUCCESS, result.status()); 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("ui_atlas", result.details().summary().identity().assetName());
assertEquals("TILES/indexed_v1", result.details().outputFormat()); assertEquals("TILES/indexed_v1", result.details().outputFormat());
assertTrue(result.diagnostics().isEmpty()); assertTrue(result.diagnostics().isEmpty());
} }
@Test @Test
void returnsOrphanDetailsForValidUnregisteredRootReference() throws Exception { void returnsUnregisteredDetailsForValidUnregisteredRootReference() throws Exception {
final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("orphan")); final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("orphan"));
final PackerAssetDetailsService service = new PackerAssetDetailsService(); final PackerAssetDetailsService service = new PackerAssetDetailsService();
final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), "orphans/ui_sounds")); final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), "orphans/ui_sounds"));
assertEquals(PackerOperationStatus.SUCCESS, result.status()); 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()); 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")); final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), "bad"));
assertEquals(PackerOperationStatus.FAILED, result.status()); 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()); assertFalse(result.diagnostics().isEmpty());
} }
@ -64,7 +68,8 @@ final class PackerAssetDetailsServiceTest {
final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(tempDir.resolve("empty")), "missing/root")); final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(tempDir.resolve("empty")), "missing/root"));
assertEquals(PackerOperationStatus.FAILED, result.status()); 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"))); assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("could not be resolved")));
} }

View File

@ -40,7 +40,7 @@ final class FileSystemPackerDoctorServiceTest {
} }
@Test @Test
void expandedWorkspaceReportsOrphanAssetsAndRegisterSafeFixes() throws Exception { void expandedWorkspaceReportsUnregisteredAssetsAndRegisterSafeFixes() throws Exception {
final Path projectRoot = createExpandedWorkspace(); final Path projectRoot = createExpandedWorkspace();
final FileSystemPackerDoctorService service = new FileSystemPackerDoctorService(); final FileSystemPackerDoctorService service = new FileSystemPackerDoctorService();
@ -52,7 +52,7 @@ final class FileSystemPackerDoctorServiceTest {
assertEquals(PackerOperationStatus.PARTIAL, result.status()); assertEquals(PackerOperationStatus.PARTIAL, result.status());
assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> assertTrue(result.diagnostics().stream().anyMatch(diagnostic ->
diagnostic.category() == PackerDiagnosticCategory.HYGIENE 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")); assertTrue(result.safeFixes().contains("register_asset orphans/ui_sounds"));
} }

View File

@ -25,37 +25,6 @@ final class FileSystemPackerMutationServiceTest {
@TempDir @TempDir
Path tempDir; Path tempDir;
@Test
void previewAndApplyQuarantineForManagedAssetShowsStructuredImpact() throws Exception {
final Path projectRoot = createManagedAssetProject();
final List<PackerEvent> 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 @Test
void applyRelocatePreservesIdentityAndUpdatesRegistryRoot() throws Exception { void applyRelocatePreservesIdentityAndUpdatesRegistryRoot() throws Exception {
final Path projectRoot = createManagedAssetProject(); final Path projectRoot = createManagedAssetProject();
@ -104,6 +73,27 @@ final class FileSystemPackerMutationServiceTest {
assertTrue(preview.blockers().stream().anyMatch(message -> message.contains("valid before registration"))); 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 @Test
void emitsFailureLifecycleWhenApplyFails() throws Exception { void emitsFailureLifecycleWhenApplyFails() throws Exception {
final Path projectRoot = createManagedAssetProject(); final Path projectRoot = createManagedAssetProject();
@ -151,7 +141,8 @@ final class FileSystemPackerMutationServiceTest {
{ {
"asset_id": 1, "asset_id": 1,
"asset_uuid": "uuid-1", "asset_uuid": "uuid-1",
"root": "ui/atlas" "root": "ui/atlas",
"included_in_build": true
} }
] ]
} }

View File

@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import p.packer.api.PackerOperationStatus; import p.packer.api.PackerOperationStatus;
import p.packer.api.PackerProjectContext; import p.packer.api.PackerProjectContext;
import p.packer.api.assets.PackerBuildParticipation;
import p.packer.api.assets.PackerAssetState; import p.packer.api.assets.PackerAssetState;
import p.packer.api.events.PackerEvent; import p.packer.api.events.PackerEvent;
import p.packer.api.events.PackerEventKind; import p.packer.api.events.PackerEventKind;
@ -23,7 +24,7 @@ final class FileSystemPackerWorkspaceServiceTest {
Path tempDir; Path tempDir;
@Test @Test
void listsManagedAndOrphanAssetsFromWorkspaceScan() throws Exception { void listsRegisteredAndUnregisteredAssetsFromWorkspaceScan() throws Exception {
final Path projectRoot = copyFixture("workspaces/read-mixed", tempDir.resolve("mixed")); final Path projectRoot = copyFixture("workspaces/read-mixed", tempDir.resolve("mixed"));
final FileSystemPackerWorkspaceService service = new FileSystemPackerWorkspaceService(); final FileSystemPackerWorkspaceService service = new FileSystemPackerWorkspaceService();
@ -31,8 +32,8 @@ final class FileSystemPackerWorkspaceServiceTest {
assertEquals(PackerOperationStatus.SUCCESS, result.status()); assertEquals(PackerOperationStatus.SUCCESS, result.status());
assertEquals(2, result.assets().size()); assertEquals(2, result.assets().size());
assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.MANAGED)); assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.REGISTERED));
assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.ORPHAN)); assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.UNREGISTERED));
} }
@Test @Test
@ -54,7 +55,8 @@ final class FileSystemPackerWorkspaceServiceTest {
final var result = service.listAssets(new ListAssetsRequest(project(projectRoot))); final var result = service.listAssets(new ListAssetsRequest(project(projectRoot)));
assertEquals(1, result.assets().size()); 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()); assertTrue(result.assets().getFirst().hasDiagnostics());
} }

View File

@ -36,7 +36,13 @@ public final class StudioWorkspaceRailControl<T> extends VBox implements StudioC
button.setPrefSize(44, 44); button.setPrefSize(44, 44);
button.setMinSize(44, 44); button.setMinSize(44, 44);
button.setMaxSize(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.setTooltip(new Tooltip());
button.getTooltip().textProperty().bind(item.label()); button.getTooltip().textProperty().bind(item.label());
button.setOnAction(ignored -> Objects.requireNonNull(onSelect, "onSelect").accept(item.id())); button.setOnAction(ignored -> Objects.requireNonNull(onSelect, "onSelect").accept(item.id()));

View File

@ -79,10 +79,13 @@ public enum I18n {
WORKSPACE_ASSETS("workspace.assets"), WORKSPACE_ASSETS("workspace.assets"),
ASSETS_NAVIGATOR_TITLE("assets.navigator.title"), 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_DETAILS_TITLE("assets.details.title"),
ASSETS_SEARCH_PROMPT("assets.search.prompt"), ASSETS_SEARCH_PROMPT("assets.search.prompt"),
ASSETS_FILTER_MANAGED("assets.filter.managed"), ASSETS_FILTER_REGISTERED("assets.filter.registered"),
ASSETS_FILTER_ORPHAN("assets.filter.orphan"), ASSETS_FILTER_UNREGISTERED("assets.filter.unregistered"),
ASSETS_FILTER_DIAGNOSTICS("assets.filter.diagnostics"), ASSETS_FILTER_DIAGNOSTICS("assets.filter.diagnostics"),
ASSETS_FILTER_PRELOAD("assets.filter.preload"), ASSETS_FILTER_PRELOAD("assets.filter.preload"),
ASSETS_STATE_LOADING("assets.state.loading"), ASSETS_STATE_LOADING("assets.state.loading"),
@ -90,8 +93,8 @@ public enum I18n {
ASSETS_STATE_NO_RESULTS("assets.state.noResults"), ASSETS_STATE_NO_RESULTS("assets.state.noResults"),
ASSETS_STATE_READY("assets.state.ready"), ASSETS_STATE_READY("assets.state.ready"),
ASSETS_STATE_ERROR("assets.state.error"), ASSETS_STATE_ERROR("assets.state.error"),
ASSETS_BADGE_MANAGED("assets.badge.managed"), ASSETS_BADGE_REGISTERED("assets.badge.registered"),
ASSETS_BADGE_ORPHAN("assets.badge.orphan"), ASSETS_BADGE_UNREGISTERED("assets.badge.unregistered"),
ASSETS_BADGE_PRELOAD("assets.badge.preload"), ASSETS_BADGE_PRELOAD("assets.badge.preload"),
ASSETS_BADGE_DIAGNOSTICS("assets.badge.diagnostics"), ASSETS_BADGE_DIAGNOSTICS("assets.badge.diagnostics"),
ASSETS_SECTION_SUMMARY("assets.section.summary"), ASSETS_SECTION_SUMMARY("assets.section.summary"),
@ -99,15 +102,10 @@ public enum I18n {
ASSETS_SECTION_INPUTS_PREVIEW("assets.section.inputsPreview"), ASSETS_SECTION_INPUTS_PREVIEW("assets.section.inputsPreview"),
ASSETS_SECTION_DIAGNOSTICS("assets.section.diagnostics"), ASSETS_SECTION_DIAGNOSTICS("assets.section.diagnostics"),
ASSETS_SECTION_ACTIONS("assets.section.actions"), 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_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_RELOCATE("assets.action.relocate"),
ASSETS_ACTION_FORGET("assets.action.forget"),
ASSETS_ACTION_REMOVE("assets.action.remove"), ASSETS_ACTION_REMOVE("assets.action.remove"),
ASSETS_MUTATION_PREVIEW_TITLE("assets.mutation.previewTitle"), ASSETS_MUTATION_PREVIEW_TITLE("assets.mutation.previewTitle"),
ASSETS_MUTATION_SECTION_CHANGES("assets.mutation.section.changes"), ASSETS_MUTATION_SECTION_CHANGES("assets.mutation.section.changes"),
@ -127,15 +125,20 @@ public enum I18n {
ASSETS_MUTATION_APPLY("assets.mutation.apply"), ASSETS_MUTATION_APPLY("assets.mutation.apply"),
ASSETS_MUTATION_CONFIRM_TITLE("assets.mutation.confirm.title"), ASSETS_MUTATION_CONFIRM_TITLE("assets.mutation.confirm.title"),
ASSETS_MUTATION_CONFIRM_HEADER("assets.mutation.confirm.header"), ASSETS_MUTATION_CONFIRM_HEADER("assets.mutation.confirm.header"),
ASSETS_MUTATION_CONFIRM_BODY("assets.mutation.confirm.body"),
ASSETS_LABEL_NAME("assets.label.name"), 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_ASSET_ID("assets.label.assetId"),
ASSETS_LABEL_TYPE("assets.label.type"), ASSETS_LABEL_TYPE("assets.label.type"),
ASSETS_LABEL_LOCATION("assets.label.location"), ASSETS_LABEL_LOCATION("assets.label.location"),
ASSETS_LABEL_TARGET_LOCATION("assets.label.targetLocation"),
ASSETS_LABEL_FORMAT("assets.label.format"), ASSETS_LABEL_FORMAT("assets.label.format"),
ASSETS_LABEL_CODEC("assets.label.codec"), ASSETS_LABEL_CODEC("assets.label.codec"),
ASSETS_LABEL_PRELOAD("assets.label.preload"), 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_YES("assets.value.yes"),
ASSETS_VALUE_NO("assets.value.no"), ASSETS_VALUE_NO("assets.value.no"),
ASSETS_PROGRESS_IDLE("assets.progress.idle"), ASSETS_PROGRESS_IDLE("assets.progress.idle"),
@ -160,6 +163,49 @@ public enum I18n {
ASSETS_DETAILS_EMPTY("assets.details.empty"), ASSETS_DETAILS_EMPTY("assets.details.empty"),
ASSETS_DETAILS_READY("assets.details.ready"), ASSETS_DETAILS_READY("assets.details.ready"),
ASSETS_DETAILS_NO_SELECTION("assets.details.noSelection"), 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"), WORKSPACE_DEBUG("workspace.debug"),

View File

@ -81,16 +81,20 @@ public final class NewProjectWizard {
feedbackLabel.setWrapText(true); feedbackLabel.setWrapText(true);
backButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BACK)); backButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BACK));
backButton.getStyleClass().addAll("studio-button", "studio-button-secondary");
backButton.setOnAction(ignored -> goBack()); backButton.setOnAction(ignored -> goBack());
nextButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_NEXT)); nextButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_NEXT));
nextButton.getStyleClass().addAll("studio-button", "studio-button-primary");
nextButton.setOnAction(ignored -> goNext()); nextButton.setOnAction(ignored -> goNext());
createButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_CREATE)); createButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_CREATE));
createButton.getStyleClass().addAll("studio-button", "studio-button-primary");
createButton.setOnAction(ignored -> finishCreate()); createButton.setOnAction(ignored -> finishCreate());
final Button cancelButton = new Button(); final Button cancelButton = new Button();
cancelButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_CANCEL)); cancelButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_CANCEL));
cancelButton.getStyleClass().addAll("studio-button", "studio-button-cancel");
cancelButton.setOnAction(ignored -> stage.close()); cancelButton.setOnAction(ignored -> stage.close());
final HBox actions = new HBox(12, backButton, nextButton, createButton, cancelButton); final HBox actions = new HBox(12, backButton, nextButton, createButton, cancelButton);
@ -155,6 +159,7 @@ public final class NewProjectWizard {
final Button browseButton = new Button(); final Button browseButton = new Button();
browseButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BROWSE)); browseButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BROWSE));
browseButton.getStyleClass().addAll("studio-button", "studio-button-secondary");
browseButton.setOnAction(ignored -> browseForLocation()); browseButton.setOnAction(ignored -> browseForLocation());
final HBox row = new HBox(12, locationField, browseButton); final HBox row = new HBox(12, locationField, browseButton);

View File

@ -88,15 +88,18 @@ public final class ProjectLauncherView extends BorderPane {
final Button openButton = new Button(); final Button openButton = new Button();
openButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_OPEN_PROJECT)); 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.disableProperty().bind(projectList.getSelectionModel().selectedItemProperty().isNull());
openButton.setOnAction(ignored -> openSelectedProject()); openButton.setOnAction(ignored -> openSelectedProject());
final Button addButton = new Button(); final Button addButton = new Button();
addButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_ADD_PROJECT)); addButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_ADD_PROJECT));
addButton.getStyleClass().addAll("studio-button", "studio-button-secondary");
addButton.setOnAction(ignored -> addExistingProject()); addButton.setOnAction(ignored -> addExistingProject());
final Button forgetButton = new Button(); final Button forgetButton = new Button();
forgetButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_FORGET_PROJECT)); 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.disableProperty().bind(projectList.getSelectionModel().selectedItemProperty().isNull());
forgetButton.setOnAction(ignored -> forgetSelectedProject()); forgetButton.setOnAction(ignored -> forgetSelectedProject());
@ -109,6 +112,7 @@ public final class ProjectLauncherView extends BorderPane {
final Button createButton = new Button(); final Button createButton = new Button();
createButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_CREATE_BUTTON)); createButton.textProperty().bind(Container.i18n().bind(I18n.LAUNCHER_CREATE_BUTTON));
createButton.getStyleClass().addAll("studio-button", "studio-button-primary");
createButton.setOnAction(ignored -> openCreateWizard()); createButton.setOnAction(ignored -> openCreateWizard());
final HBox createRow = new HBox(10, createButton); final HBox createRow = new HBox(10, createButton);

View File

@ -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<AssetTypeOption> 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<String> assetTypes() {
return TYPES.stream().map(AssetTypeOption::assetType).toList();
}
public static List<String> outputFormatsFor(String assetType) {
return findType(assetType).formats().stream().map(AssetFormatOption::outputFormat).toList();
}
public static List<String> 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<AssetFormatOption> formats) {
AssetTypeOption {
formats = List.copyOf(formats);
}
}
record AssetFormatOption(
String outputFormat,
List<String> codecs) {
AssetFormatOption {
codecs = List.copyOf(codecs);
}
}
}

View File

@ -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.");
}
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,7 @@
package p.studio.workspaces.assets;
import p.studio.projects.ProjectReference;
public interface AssetCreationService {
AssetCreationResult create(ProjectReference projectReference, AssetCreationRequest request);
}

View File

@ -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<AssetCreationResult> 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<String> typeCombo = new ComboBox<>();
private final ComboBox<String> formatCombo = new ComboBox<>();
private final ComboBox<String> 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<AssetCreationResult> 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("");
}
}

View File

@ -1,8 +1,8 @@
package p.studio.workspaces.assets; package p.studio.workspaces.assets;
public enum AssetNavigatorFilter { public enum AssetNavigatorFilter {
MANAGED, REGISTERED,
ORPHAN, UNREGISTERED,
DIAGNOSTICS, DIAGNOSTICS,
PRELOAD PRELOAD
} }

View File

@ -44,11 +44,11 @@ public final class AssetNavigatorProjectionBuilder {
return true; return true;
} }
final boolean includeManaged = filters.contains(AssetNavigatorFilter.MANAGED); final boolean includeRegistered = filters.contains(AssetNavigatorFilter.REGISTERED);
final boolean includeOrphan = filters.contains(AssetNavigatorFilter.ORPHAN); final boolean includeUnregistered = filters.contains(AssetNavigatorFilter.UNREGISTERED);
if (includeManaged || includeOrphan) { if (includeRegistered || includeUnregistered) {
final boolean stateMatches = (includeManaged && asset.state() == AssetWorkspaceAssetState.MANAGED) final boolean stateMatches = (includeRegistered && asset.state() == AssetWorkspaceAssetState.REGISTERED)
|| (includeOrphan && asset.state() == AssetWorkspaceAssetState.ORPHAN); || (includeUnregistered && asset.state() == AssetWorkspaceAssetState.UNREGISTERED);
if (!stateMatches) { if (!stateMatches) {
return false; return false;
} }

View File

@ -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();
}
}

View File

@ -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<AssetWorkspaceMutationPreview> 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<AssetWorkspaceMutationPreview> 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<AssetWorkspaceMutationChange> 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<String> 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('\\', '/');
}
}

View File

@ -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);
}
}

View File

@ -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('\\', '/'));
}
}

View File

@ -1,12 +1,9 @@
package p.studio.workspaces.assets; package p.studio.workspaces.assets;
public enum AssetWorkspaceAction { public enum AssetWorkspaceAction {
DOCTOR,
BUILD,
ADOPT,
REGISTER, REGISTER,
QUARANTINE, INCLUDE_IN_BUILD,
EXCLUDE_FROM_BUILD,
RELOCATE, RELOCATE,
FORGET,
REMOVE REMOVE
} }

View File

@ -10,18 +10,21 @@ public final class AssetWorkspaceActionSetBuilder {
public static AssetWorkspaceActionSet forAsset(AssetWorkspaceAssetSummary summary) { public static AssetWorkspaceActionSet forAsset(AssetWorkspaceAssetSummary summary) {
Objects.requireNonNull(summary, "summary"); Objects.requireNonNull(summary, "summary");
return switch (summary.state()) { return switch (summary.state()) {
case MANAGED -> new AssetWorkspaceActionSet( case REGISTERED -> summary.buildParticipation() == AssetWorkspaceBuildParticipation.INCLUDED
List.of(AssetWorkspaceAction.DOCTOR, AssetWorkspaceAction.BUILD), ? new AssetWorkspaceActionSet(
List.of(),
List.of(
AssetWorkspaceAction.EXCLUDE_FROM_BUILD,
AssetWorkspaceAction.RELOCATE,
AssetWorkspaceAction.REMOVE))
: new AssetWorkspaceActionSet(
List.of(AssetWorkspaceAction.INCLUDE_IN_BUILD),
List.of( List.of(
AssetWorkspaceAction.FORGET,
AssetWorkspaceAction.QUARANTINE,
AssetWorkspaceAction.RELOCATE, AssetWorkspaceAction.RELOCATE,
AssetWorkspaceAction.REMOVE)); AssetWorkspaceAction.REMOVE));
case ORPHAN -> new AssetWorkspaceActionSet( case UNREGISTERED -> new AssetWorkspaceActionSet(
List.of(AssetWorkspaceAction.ADOPT, AssetWorkspaceAction.REGISTER), List.of(AssetWorkspaceAction.REGISTER),
List.of( List.of(AssetWorkspaceAction.RELOCATE));
AssetWorkspaceAction.QUARANTINE,
AssetWorkspaceAction.RELOCATE));
}; };
} }
} }

View File

@ -1,6 +1,6 @@
package p.studio.workspaces.assets; package p.studio.workspaces.assets;
public enum AssetWorkspaceAssetState { public enum AssetWorkspaceAssetState {
MANAGED, REGISTERED,
ORPHAN UNREGISTERED
} }

View File

@ -7,6 +7,7 @@ public record AssetWorkspaceAssetSummary(
AssetWorkspaceSelectionKey selectionKey, AssetWorkspaceSelectionKey selectionKey,
String assetName, String assetName,
AssetWorkspaceAssetState state, AssetWorkspaceAssetState state,
AssetWorkspaceBuildParticipation buildParticipation,
Integer assetId, Integer assetId,
String assetFamily, String assetFamily,
Path assetRoot, Path assetRoot,
@ -17,13 +18,17 @@ public record AssetWorkspaceAssetSummary(
Objects.requireNonNull(selectionKey, "selectionKey"); Objects.requireNonNull(selectionKey, "selectionKey");
assetName = Objects.requireNonNull(assetName, "assetName").trim(); assetName = Objects.requireNonNull(assetName, "assetName").trim();
Objects.requireNonNull(state, "state"); Objects.requireNonNull(state, "state");
Objects.requireNonNull(buildParticipation, "buildParticipation");
assetFamily = Objects.requireNonNullElse(assetFamily, "unknown").trim(); assetFamily = Objects.requireNonNullElse(assetFamily, "unknown").trim();
assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize();
if (assetName.isBlank()) { if (assetName.isBlank()) {
throw new IllegalArgumentException("assetName must not be blank"); throw new IllegalArgumentException("assetName must not be blank");
} }
if (state == AssetWorkspaceAssetState.MANAGED && assetId == null) { if (state == AssetWorkspaceAssetState.REGISTERED && assetId == null) {
throw new IllegalArgumentException("managed asset must expose assetId"); 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");
} }
} }
} }

View File

@ -0,0 +1,6 @@
package p.studio.workspaces.assets;
public enum AssetWorkspaceBuildParticipation {
INCLUDED,
EXCLUDED
}

View File

@ -2,8 +2,21 @@ package p.studio.workspaces.assets;
import p.studio.projects.ProjectReference; import p.studio.projects.ProjectReference;
import java.nio.file.Path;
public interface AssetWorkspaceMutationService { 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); void apply(ProjectReference projectReference, AssetWorkspaceMutationPreview preview);
} }

View File

@ -15,11 +15,13 @@ import java.util.stream.Stream;
public final class FileSystemAssetWorkspaceMutationService implements AssetWorkspaceMutationService { public final class FileSystemAssetWorkspaceMutationService implements AssetWorkspaceMutationService {
private static final ObjectMapper MAPPER = new ObjectMapper(); private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String PROMETEU_DIR = ".prometeu"; private static final String PROMETEU_DIR = ".prometeu";
private static final String QUARANTINE_DIR = "quarantine";
private static final String RECOVERED_DIR = "recovered";
@Override @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(projectReference, "projectReference");
Objects.requireNonNull(asset, "asset"); Objects.requireNonNull(asset, "asset");
Objects.requireNonNull(action, "action"); Objects.requireNonNull(action, "action");
@ -37,45 +39,59 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks
} }
switch (action) { switch (action) {
case ADOPT, REGISTER -> { case REGISTER -> {
if (asset.state() == AssetWorkspaceAssetState.MANAGED) { if (asset.state() == AssetWorkspaceAssetState.REGISTERED) {
blockers.add("Asset is already managed."); blockers.add("Asset is already registered.");
} else { } else {
changes.add(new AssetWorkspaceMutationChange( changes.add(new AssetWorkspaceMutationChange(
AssetWorkspaceMutationChangeScope.REGISTRY, AssetWorkspaceMutationChangeScope.REGISTRY,
"ADD", "ADD",
relativeRoot)); relativeRoot));
if (asset.hasDiagnostics()) { 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 -> { case INCLUDE_IN_BUILD -> {
if (isInsideQuarantine(asset.assetRoot(), assetsRoot)) { if (asset.state() != AssetWorkspaceAssetState.REGISTERED) {
blockers.add("Asset is already inside quarantine."); 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 { } 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( changes.add(new AssetWorkspaceMutationChange(
AssetWorkspaceMutationChangeScope.WORKSPACE, AssetWorkspaceMutationChangeScope.REGISTRY,
"MOVE", "UPDATE",
relativeRoot + " -> " + relativeAssetRoot(targetAssetRoot, assetsRoot))); relativeRoot));
warnings.add("Quarantine is explicit and reversible, but the asset will leave its current workspace location."); 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 -> { case RELOCATE -> {
targetAssetRoot = relocationTarget(asset, assetsRoot); targetAssetRoot = targetRoot == null
final String targetRelativeRoot = relativeAssetRoot(targetAssetRoot, assetsRoot); ? relocationTarget(asset, assetsRoot)
if (asset.assetRoot().equals(targetAssetRoot)) { : targetRoot.toAbsolutePath().normalize();
blockers.add("Asset is already at the planned relocation target."); final AssetRootValidationResult validation = AssetRelocationTargetValidator.validate(
projectReference,
asset.assetRoot(),
targetAssetRoot);
if (!validation.valid()) {
blockers.add(validation.message());
} else { } else {
if (asset.state() == AssetWorkspaceAssetState.MANAGED) { targetAssetRoot = validation.assetRoot();
final String targetRelativeRoot = validation.normalizedRelativeRoot();
if (asset.state() == AssetWorkspaceAssetState.REGISTERED) {
changes.add(new AssetWorkspaceMutationChange( changes.add(new AssetWorkspaceMutationChange(
AssetWorkspaceMutationChangeScope.REGISTRY, AssetWorkspaceMutationChangeScope.REGISTRY,
"UPDATE", "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."); 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 -> { case REMOVE -> {
if (asset.state() == AssetWorkspaceAssetState.MANAGED) { if (asset.state() == AssetWorkspaceAssetState.REGISTERED) {
changes.add(new AssetWorkspaceMutationChange( changes.add(new AssetWorkspaceMutationChange(
AssetWorkspaceMutationChangeScope.REGISTRY, AssetWorkspaceMutationChangeScope.REGISTRY,
"REMOVE", "REMOVE",
@ -112,7 +117,6 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks
relativeRoot)); relativeRoot));
warnings.add("Physical files inside the asset root will be deleted."); 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; 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(); final Path assetRoot = preview.asset().assetRoot();
switch (preview.action()) { switch (preview.action()) {
case ADOPT, REGISTER -> { case REGISTER -> {
if (registryContainsRoot(registry, assetRoot, assetsRoot)) { if (registryContainsRoot(registry, assetRoot, assetsRoot)) {
return; return;
} }
@ -145,19 +149,17 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks
registry.nextAssetId = entry.assetId + 1; registry.nextAssetId = entry.assetId + 1;
writeRegistry(assetsRoot, registry); writeRegistry(assetsRoot, registry);
} }
case FORGET -> { case INCLUDE_IN_BUILD, EXCLUDE_FROM_BUILD -> {
registry.assets.removeIf(entry -> entry.root != null && assetsRoot.resolve(entry.root).toAbsolutePath().normalize().equals(assetRoot)); 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); 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 -> { case RELOCATE -> {
final Path targetAssetRoot = requireTargetAssetRoot(preview); final Path targetAssetRoot = requireTargetAssetRoot(preview);
if (preview.asset().state() == AssetWorkspaceAssetState.MANAGED) { if (preview.asset().state() == AssetWorkspaceAssetState.REGISTERED) {
registry.assets.stream() registry.assets.stream()
.filter(entry -> entry.root != null && assetsRoot.resolve(entry.root).toAbsolutePath().normalize().equals(assetRoot)) .filter(entry -> entry.root != null && assetsRoot.resolve(entry.root).toAbsolutePath().normalize().equals(assetRoot))
.findFirst() .findFirst()
@ -171,8 +173,6 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks
writeRegistry(assetsRoot, registry); writeRegistry(assetsRoot, registry);
deleteRecursively(assetRoot); deleteRecursively(assetRoot);
} }
case DOCTOR, BUILD -> {
}
} }
} }
@ -223,34 +223,12 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks
return assetsRoot.relativize(assetRoot.toAbsolutePath().normalize()).toString().replace('\\', '/'); 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) { private Path relocationTarget(AssetWorkspaceAssetSummary asset, Path assetsRoot) {
final Path assetRoot = asset.assetRoot(); 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(); final Path siblingParent = assetRoot.getParent() == null ? assetsRoot : assetRoot.getParent();
return nextAvailableSibling(siblingParent, assetRoot.getFileName().toString() + "-relocated"); 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) { private Path nextAvailableSibling(Path parent, String baseName) {
final Path normalizedParent = parent.toAbsolutePath().normalize(); final Path normalizedParent = parent.toAbsolutePath().normalize();
Path candidate = normalizedParent.resolve(baseName); Path candidate = normalizedParent.resolve(baseName);
@ -262,16 +240,6 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks
return candidate; 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) { private Path requireTargetAssetRoot(AssetWorkspaceMutationPreview preview) {
if (preview.targetAssetRoot() == null) { if (preview.targetAssetRoot() == null) {
throw new IllegalStateException("Mutation preview does not define a target asset root"); throw new IllegalStateException("Mutation preview does not define a target asset root");
@ -330,5 +298,8 @@ public final class FileSystemAssetWorkspaceMutationService implements AssetWorks
@JsonProperty("root") @JsonProperty("root")
public String root; public String root;
@JsonProperty("included_in_build")
public boolean includedInBuild = true;
} }
} }

View File

@ -23,7 +23,7 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ
return new AssetWorkspaceSnapshot(List.of()); return new AssetWorkspaceSnapshot(List.of());
} }
final Map<Path, Integer> registryByRoot = readRegistry(assetsRoot); final Map<Path, RegistryEntry> registryByRoot = readRegistry(assetsRoot);
try (Stream<Path> paths = Files.find(assetsRoot, Integer.MAX_VALUE, (path, attrs) -> try (Stream<Path> paths = Files.find(assetsRoot, Integer.MAX_VALUE, (path, attrs) ->
attrs.isRegularFile() && path.getFileName().toString().equals("asset.json"))) { attrs.isRegularFile() && path.getFileName().toString().equals("asset.json"))) {
final List<AssetWorkspaceAssetSummary> assets = paths final List<AssetWorkspaceAssetSummary> assets = paths
@ -42,7 +42,7 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ
public AssetWorkspaceAssetDetails loadAssetDetails(ProjectReference projectReference, AssetWorkspaceSelectionKey selectionKey) { public AssetWorkspaceAssetDetails loadAssetDetails(ProjectReference projectReference, AssetWorkspaceSelectionKey selectionKey) {
final Path projectRoot = Objects.requireNonNull(projectReference, "projectReference").rootPath(); final Path projectRoot = Objects.requireNonNull(projectReference, "projectReference").rootPath();
final Path assetsRoot = projectRoot.resolve("assets"); final Path assetsRoot = projectRoot.resolve("assets");
final Map<Path, Integer> registryByRoot = readRegistry(assetsRoot); final Map<Path, RegistryEntry> registryByRoot = readRegistry(assetsRoot);
final Path assetRoot = resolveAssetRoot(selectionKey, assetsRoot, registryByRoot); final Path assetRoot = resolveAssetRoot(selectionKey, assetsRoot, registryByRoot);
final Path assetManifestPath = assetRoot.resolve("asset.json"); final Path assetManifestPath = assetRoot.resolve("asset.json");
try { try {
@ -65,14 +65,15 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ
} }
} }
private AssetWorkspaceAssetSummary buildAssetSummary(Path assetManifestPath, Map<Path, Integer> registryByRoot) { private AssetWorkspaceAssetSummary buildAssetSummary(Path assetManifestPath, Map<Path, RegistryEntry> registryByRoot) {
final Path assetRoot = assetManifestPath.getParent().toAbsolutePath().normalize(); final Path assetRoot = assetManifestPath.getParent().toAbsolutePath().normalize();
try { try {
final AssetManifest manifest = MAPPER.readValue(assetManifestPath.toFile(), AssetManifest.class); 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 final AssetWorkspaceAssetState state = assetId == null
? AssetWorkspaceAssetState.ORPHAN ? AssetWorkspaceAssetState.UNREGISTERED
: AssetWorkspaceAssetState.MANAGED; : AssetWorkspaceAssetState.REGISTERED;
final AssetWorkspaceSelectionKey selectionKey = assetId == null final AssetWorkspaceSelectionKey selectionKey = assetId == null
? new AssetWorkspaceSelectionKey.OrphanAsset(assetRoot) ? new AssetWorkspaceSelectionKey.OrphanAsset(assetRoot)
: new AssetWorkspaceSelectionKey.ManagedAsset(assetId); : new AssetWorkspaceSelectionKey.ManagedAsset(assetId);
@ -87,6 +88,9 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ
selectionKey, selectionKey,
assetName, assetName,
state, state,
state == AssetWorkspaceAssetState.REGISTERED
? (registryEntry.includedInBuild() ? AssetWorkspaceBuildParticipation.INCLUDED : AssetWorkspaceBuildParticipation.EXCLUDED)
: AssetWorkspaceBuildParticipation.EXCLUDED,
assetId, assetId,
assetFamily, assetFamily,
assetRoot, assetRoot,
@ -97,7 +101,8 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ
return new AssetWorkspaceAssetSummary( return new AssetWorkspaceAssetSummary(
selectionKey, selectionKey,
assetRoot.getFileName().toString(), assetRoot.getFileName().toString(),
AssetWorkspaceAssetState.ORPHAN, AssetWorkspaceAssetState.UNREGISTERED,
AssetWorkspaceBuildParticipation.EXCLUDED,
null, null,
"unknown", "unknown",
assetRoot, assetRoot,
@ -106,7 +111,7 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ
} }
} }
private Map<Path, Integer> readRegistry(Path assetsRoot) { private Map<Path, RegistryEntry> readRegistry(Path assetsRoot) {
final Path registryPath = assetsRoot.resolve(".prometeu").resolve("index.json"); final Path registryPath = assetsRoot.resolve(".prometeu").resolve("index.json");
if (!Files.isRegularFile(registryPath)) { if (!Files.isRegularFile(registryPath)) {
return Map.of(); return Map.of();
@ -117,12 +122,12 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ
return Map.of(); return Map.of();
} }
final Map<Path, Integer> registryByRoot = new HashMap<>(); final Map<Path, RegistryEntry> registryByRoot = new HashMap<>();
for (RegistryEntry entry : registry.assets()) { for (RegistryEntry entry : registry.assets()) {
if (entry == null || entry.root() == null || entry.root().isBlank() || entry.assetId() == null) { if (entry == null || entry.root() == null || entry.root().isBlank() || entry.assetId() == null) {
continue; continue;
} }
registryByRoot.put(assetsRoot.resolve(entry.root()).toAbsolutePath().normalize(), entry.assetId()); registryByRoot.put(assetsRoot.resolve(entry.root()).toAbsolutePath().normalize(), entry);
} }
return Map.copyOf(registryByRoot); return Map.copyOf(registryByRoot);
} catch (IOException ioException) { } catch (IOException ioException) {
@ -133,10 +138,10 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ
private Path resolveAssetRoot( private Path resolveAssetRoot(
AssetWorkspaceSelectionKey selectionKey, AssetWorkspaceSelectionKey selectionKey,
Path assetsRoot, Path assetsRoot,
Map<Path, Integer> registryByRoot) { Map<Path, RegistryEntry> registryByRoot) {
return switch (selectionKey) { return switch (selectionKey) {
case AssetWorkspaceSelectionKey.ManagedAsset managedAsset -> registryByRoot.entrySet().stream() case AssetWorkspaceSelectionKey.ManagedAsset managedAsset -> registryByRoot.entrySet().stream()
.filter(entry -> managedAsset.assetId() == entry.getValue()) .filter(entry -> managedAsset.assetId() == entry.getValue().assetId())
.map(Map.Entry::getKey) .map(Map.Entry::getKey)
.findFirst() .findFirst()
.orElseThrow(() -> new IllegalArgumentException("managed asset root not found for assetId " + managedAsset.assetId())); .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) @JsonIgnoreProperties(ignoreUnknown = true)
private record RegistryEntry(Integer assetId, String root) { private record RegistryEntry(Integer assetId, String root, boolean includedInBuild) {
private RegistryEntry( private RegistryEntry(
@com.fasterxml.jackson.annotation.JsonProperty("asset_id") Integer assetId, @com.fasterxml.jackson.annotation.JsonProperty("asset_id") Integer assetId,
@com.fasterxml.jackson.annotation.JsonProperty("root") String root) { @com.fasterxml.jackson.annotation.JsonProperty("root") String root,
this.assetId = assetId; @com.fasterxml.jackson.annotation.JsonProperty("included_in_build") Boolean includedInBuild) {
this.root = root; this(assetId, root, includedInBuild == null || includedInBuild);
} }
} }
} }

View File

@ -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<String, Object> manifestDocument(AssetCreationRequest request) {
final Map<String, Object> 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<String, Object> output = new LinkedHashMap<>();
output.put("format", request.outputFormat());
output.put("codec", request.outputCodec());
root.put("output", output);
final Map<String, Object> preload = new LinkedHashMap<>();
preload.put("enabled", request.preloadEnabled());
root.put("preload", preload);
return root;
}
}

View File

@ -48,15 +48,34 @@ public final class PackerBackedAssetWorkspaceMutationService implements AssetWor
} }
@Override @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(projectReference, "projectReference");
Objects.requireNonNull(asset, "asset"); 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( final PackerMutationPreview packerPreview = packerMutationService.preview(
new PackerMutationRequest( new PackerMutationRequest(
project(projectReference), project(projectReference),
mutationType(action), mutationType(action),
assetReference(projectReference, asset), assetReference(projectReference, asset),
null)); validatedTargetRoot));
final AssetWorkspaceMutationPreview studioPreview = mapPreview(action, asset, packerPreview); final AssetWorkspaceMutationPreview studioPreview = mapPreview(action, asset, packerPreview);
final OperationSession session = new OperationSession(projectReference, action, studioPreview, packerPreview); final OperationSession session = new OperationSession(projectReference, action, studioPreview, packerPreview);
previewSessions.put(studioPreview, session); previewSessions.put(studioPreview, session);
@ -135,13 +154,11 @@ public final class PackerBackedAssetWorkspaceMutationService implements AssetWor
private PackerMutationType mutationType(AssetWorkspaceAction action) { private PackerMutationType mutationType(AssetWorkspaceAction action) {
return switch (Objects.requireNonNull(action, "action")) { return switch (Objects.requireNonNull(action, "action")) {
case ADOPT -> PackerMutationType.ADOPT_ASSET;
case REGISTER -> PackerMutationType.REGISTER_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 RELOCATE -> PackerMutationType.RELOCATE_ASSET;
case FORGET -> PackerMutationType.FORGET_ASSET;
case REMOVE -> PackerMutationType.REMOVE_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(); 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( private record OperationSession(
ProjectReference projectReference, ProjectReference projectReference,
AssetWorkspaceAction action, AssetWorkspaceAction action,

View File

@ -1,6 +1,7 @@
package p.studio.workspaces.assets; package p.studio.workspaces.assets;
import p.packer.api.PackerProjectContext; import p.packer.api.PackerProjectContext;
import p.packer.api.assets.PackerBuildParticipation;
import p.packer.api.assets.PackerAssetDetails; import p.packer.api.assets.PackerAssetDetails;
import p.packer.api.assets.PackerAssetState; import p.packer.api.assets.PackerAssetState;
import p.packer.api.assets.PackerAssetSummary; import p.packer.api.assets.PackerAssetSummary;
@ -47,13 +48,14 @@ public final class PackerBackedAssetWorkspaceService implements AssetWorkspaceSe
private AssetWorkspaceAssetSummary mapSummary(PackerAssetSummary summary) { private AssetWorkspaceAssetSummary mapSummary(PackerAssetSummary summary) {
final AssetWorkspaceAssetState state = toStudioState(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.ManagedAsset(summary.identity().assetId())
: new AssetWorkspaceSelectionKey.OrphanAsset(summary.identity().assetRoot()); : new AssetWorkspaceSelectionKey.OrphanAsset(summary.identity().assetRoot());
return new AssetWorkspaceAssetSummary( return new AssetWorkspaceAssetSummary(
selectionKey, selectionKey,
summary.identity().assetName(), summary.identity().assetName(),
state, state,
toBuildParticipation(summary.buildParticipation()),
summary.identity().assetId(), summary.identity().assetId(),
summary.assetFamily(), summary.assetFamily(),
summary.identity().assetRoot(), summary.identity().assetRoot(),
@ -81,10 +83,17 @@ public final class PackerBackedAssetWorkspaceService implements AssetWorkspaceSe
} }
private AssetWorkspaceAssetState toStudioState(PackerAssetSummary summary) { private AssetWorkspaceAssetState toStudioState(PackerAssetSummary summary) {
if (summary.state() == PackerAssetState.MANAGED) { if (summary.state() == PackerAssetState.REGISTERED) {
return AssetWorkspaceAssetState.MANAGED; 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) { private PackerProjectContext project(ProjectReference projectReference) {

View File

@ -53,6 +53,7 @@ public class BuilderWorkspace implements Workspace {
private ToolBar buildToolBar() { private ToolBar buildToolBar() {
buildButton.textProperty().bind(Container.i18n().bind(I18n.WORKSPACE_SHIPPER_BUTTON_RUN)); buildButton.textProperty().bind(Container.i18n().bind(I18n.WORKSPACE_SHIPPER_BUTTON_RUN));
buildButton.getStyleClass().addAll("studio-button", "studio-button-primary");
buildButton.setOnAction(e -> { buildButton.setOnAction(e -> {
logs.clear(); logs.clear();
final var logAggregator = LogAggregator.with(logs::appendText); 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.textProperty().bind(Container.i18n().bind(I18n.WORKSPACE_SHIPPER_BUTTON_CLEAR));
clearButton.getStyleClass().addAll("studio-button", "studio-button-secondary");
clearButton.setOnAction(e -> logs.clear()); clearButton.setOnAction(e -> logs.clear());
return new ToolBar(buildButton, clearButton); return new ToolBar(buildButton, clearButton);

View File

@ -17,7 +17,8 @@ public final class EditorToolbar extends HBox {
Button saveBtn = iconButton("💾", "Save"); Button saveBtn = iconButton("💾", "Save");
Button runBtn = iconButton("", "Run"); Button runBtn = iconButton("", "Run");
runBtn.getStyleClass().add("accent"); runBtn.getStyleClass().remove("studio-button-secondary");
runBtn.getStyleClass().add("studio-button-primary");
Region spacer = new Region(); Region spacer = new Region();
HBox.setHgrow(spacer, javafx.scene.layout.Priority.ALWAYS); 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) { private Button iconButton(String icon, String tooltip) {
Button b = new Button(icon); Button b = new Button(icon);
b.setFocusTraversable(false); b.setFocusTraversable(false);
b.getStyleClass().add("toolbar-button"); b.getStyleClass().addAll("studio-button", "studio-button-secondary", "studio-button-icon");
return b; return b;
} }
} }

View File

@ -69,19 +69,22 @@ workspace.shipper.button.clear=Clear
workspace.assets=Assets workspace.assets=Assets
assets.navigator.title=Asset Navigator 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.details.title=Selected Asset
assets.search.prompt=Search assets by name or path assets.search.prompt=Search assets by name or path
assets.filter.managed=Managed assets.filter.registered=Registered
assets.filter.orphan=Orphan assets.filter.unregistered=Unregistered
assets.filter.diagnostics=Diagnostics assets.filter.diagnostics=Diagnostics
assets.filter.preload=Preload assets.filter.preload=Preload
assets.state.loading=Loading assets... 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.noResults=No assets match the current search or filters.
assets.state.ready={0} visible assets ({1} total). assets.state.ready={0} visible assets ({1} total).
assets.state.error=Asset workspace failed to load. assets.state.error=Asset workspace failed to load.
assets.badge.managed=Managed assets.badge.registered=Registered
assets.badge.orphan=Orphan assets.badge.unregistered=Unregistered
assets.badge.preload=Preload assets.badge.preload=Preload
assets.badge.diagnostics=Diagnostics assets.badge.diagnostics=Diagnostics
assets.section.summary=Summary assets.section.summary=Summary
@ -89,15 +92,10 @@ assets.section.runtimeContract=Runtime Contract
assets.section.inputsPreview=Inputs / Preview assets.section.inputsPreview=Inputs / Preview
assets.section.diagnostics=Diagnostics assets.section.diagnostics=Diagnostics
assets.section.actions=Actions 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.register=Register
assets.action.quarantine=Quarantine assets.action.includeInBuild=Include In Build
assets.action.excludeFromBuild=Exclude From Build
assets.action.relocate=Relocate assets.action.relocate=Relocate
assets.action.forget=Forget
assets.action.remove=Remove assets.action.remove=Remove
assets.mutation.previewTitle=Preview: {0} assets.mutation.previewTitle=Preview: {0}
assets.mutation.section.changes=Changes assets.mutation.section.changes=Changes
@ -115,17 +113,22 @@ assets.mutation.empty.warnings=No warnings.
assets.mutation.empty.safeFixes=No safe fixes. assets.mutation.empty.safeFixes=No safe fixes.
assets.mutation.cancel=Cancel assets.mutation.cancel=Cancel
assets.mutation.apply=Apply 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.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.name=Name
assets.label.state=State assets.label.registration=Registration
assets.label.buildParticipation=Build Participation
assets.label.assetId=Asset ID assets.label.assetId=Asset ID
assets.label.type=Type assets.label.type=Type
assets.label.location=Location assets.label.location=Location
assets.label.targetLocation=Target Location
assets.label.format=Format assets.label.format=Format
assets.label.codec=Codec assets.label.codec=Codec
assets.label.preload=Preload 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.yes=Yes
assets.value.no=No assets.value.no=No
assets.progress.idle=Assets workspace idle. 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.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.ready=Selected asset: {0}\nState: {1}\nRoot: {2}
assets.details.noSelection=Select an asset from the navigator once assets are available. 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 workspace.debug=Debug

View File

@ -26,21 +26,6 @@
-fx-border-width: 0 1 0 0; -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 { .studio-right-utility-panel {
-fx-background-color: #1f1f1f; -fx-background-color: #1f1f1f;
-fx-border-color: #2d2d2d; -fx-border-color: #2d2d2d;
@ -108,6 +93,113 @@
-fx-font-weight: bold; -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 { .studio-utility-placeholder {
-fx-text-fill: #d4d4d4; -fx-text-fill: #d4d4d4;
-fx-padding: 16; -fx-padding: 16;
@ -190,6 +282,12 @@
-fx-accent: #5cb6ff; -fx-accent: #5cb6ff;
} }
.assets-workspace-action-bar {
-fx-alignment: center-left;
-fx-spacing: 8;
-fx-padding: 0 0 0 8;
}
.assets-workspace-pane-title { .assets-workspace-pane-title {
-fx-text-fill: #f3f7fb; -fx-text-fill: #f3f7fb;
-fx-font-size: 15px; -fx-font-size: 15px;
@ -209,21 +307,6 @@
-fx-padding: 4 0 2 0; -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 { .assets-workspace-summary {
-fx-text-fill: #9ecbff; -fx-text-fill: #9ecbff;
-fx-font-size: 12px; -fx-font-size: 12px;
@ -275,21 +358,91 @@
-fx-border-color: #4f8dc3; -fx-border-color: #4f8dc3;
} }
.assets-workspace-asset-icon { .assets-workspace-asset-row-selected:hover {
-fx-font-size: 14px; -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 { .assets-workspace-asset-name {
-fx-text-fill: #f6fbff; -fx-text-fill: #f6fbff;
-fx-font-size: 13px; -fx-font-size: 15px;
-fx-font-weight: bold; -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 { .assets-workspace-asset-path {
-fx-text-fill: #9eacbb; -fx-text-fill: #9eacbb;
-fx-font-size: 11px; -fx-font-size: 11px;
} }
.assets-workspace-asset-badges {
-fx-alignment: center-right;
-fx-padding: 0 4 0 10;
}
.assets-workspace-badge { .assets-workspace-badge {
-fx-font-size: 10px; -fx-font-size: 10px;
-fx-padding: 3 7 3 7; -fx-padding: 3 7 3 7;
@ -298,24 +451,12 @@
-fx-border-width: 1; -fx-border-width: 1;
} }
.assets-workspace-badge-managed {
-fx-background-color: #153425;
-fx-border-color: #2f8f59;
-fx-text-fill: #8ce3ae;
}
.assets-workspace-badge-orphan { .assets-workspace-badge-orphan {
-fx-background-color: #3a2d14; -fx-background-color: #3a2d14;
-fx-border-color: #bc8a31; -fx-border-color: #bc8a31;
-fx-text-fill: #ffd27a; -fx-text-fill: #ffd27a;
} }
.assets-workspace-badge-family {
-fx-background-color: #1c2733;
-fx-border-color: #38506a;
-fx-text-fill: #b7d8f8;
}
.assets-workspace-badge-preload { .assets-workspace-badge-preload {
-fx-background-color: #271747; -fx-background-color: #271747;
-fx-border-color: #7f65cf; -fx-border-color: #7f65cf;
@ -348,6 +489,20 @@
-fx-padding: 4 0 4 0; -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 { .assets-details-section {
-fx-background-color: #11151b; -fx-background-color: #11151b;
-fx-background-radius: 12; -fx-background-radius: 12;
@ -385,6 +540,27 @@
-fx-font-size: 12px; -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 { .assets-details-role-label {
-fx-text-fill: #9fc3e7; -fx-text-fill: #9fc3e7;
-fx-font-size: 11px; -fx-font-size: 11px;
@ -392,19 +568,9 @@
} }
.assets-details-input-button { .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; -fx-alignment: center-left;
} }
.assets-details-input-button-selected {
-fx-background-color: #224160;
-fx-border-color: #4f8dc3;
}
.assets-details-input-preview-split { .assets-details-input-preview-split {
-fx-background-color: transparent; -fx-background-color: transparent;
} }
@ -432,19 +598,6 @@
-fx-font-weight: bold; -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 { .assets-details-preview-text {
-fx-control-inner-background: #10161d; -fx-control-inner-background: #10161d;
-fx-text-fill: #e4edf6; -fx-text-fill: #e4edf6;
@ -484,25 +637,6 @@
-fx-font-size: 12px; -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 { .assets-mutation-panel {
-fx-background-color: #0f1318; -fx-background-color: #0f1318;
-fx-background-radius: 12; -fx-background-radius: 12;
@ -543,22 +677,6 @@
-fx-font-size: 12px; -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 { .assets-workspace-logs-pane {
-fx-collapsible: true; -fx-collapsible: true;
} }

View File

@ -54,12 +54,12 @@ final class StudioActivityEventMapperTest {
@Test @Test
void mapsMutationPreviewReadyToInfoEntry() { void mapsMutationPreviewReadyToInfoEntry() {
final StudioActivityEntry entry = StudioActivityEventMapper final StudioActivityEntry entry = StudioActivityEventMapper
.map(new StudioAssetsMutationPreviewReadyEvent(project(), AssetWorkspaceAction.QUARANTINE, 1)) .map(new StudioAssetsMutationPreviewReadyEvent(project(), AssetWorkspaceAction.RELOCATE, 1))
.orElseThrow(); .orElseThrow();
assertEquals("Assets", entry.source()); assertEquals("Assets", entry.source());
assertEquals(StudioActivityEntrySeverity.INFO, entry.severity()); assertEquals(StudioActivityEntrySeverity.INFO, entry.severity());
assertEquals("Preview ready: quarantine", entry.message()); assertEquals("Preview ready: relocate", entry.message());
} }
@Test @Test

View File

@ -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"));
}
}

View File

@ -15,8 +15,8 @@ final class AssetNavigatorProjectionBuilderTest {
final Path assetsRoot = projectRoot.resolve("assets"); final Path assetsRoot = projectRoot.resolve("assets");
final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build( final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build(
List.of( List.of(
managedAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false), registeredAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false),
orphanAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)), unregisteredAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)),
projectRoot, projectRoot,
"", "",
EnumSet.noneOf(AssetNavigatorFilter.class)); EnumSet.noneOf(AssetNavigatorFilter.class));
@ -26,16 +26,16 @@ final class AssetNavigatorProjectionBuilderTest {
} }
@Test @Test
void managedAndOrphanFiltersBehaveAsStateFilterSet() { void registeredAndUnregisteredFiltersBehaveAsStateFilterSet() {
final Path projectRoot = Path.of("/tmp/project"); final Path projectRoot = Path.of("/tmp/project");
final Path assetsRoot = projectRoot.resolve("assets"); final Path assetsRoot = projectRoot.resolve("assets");
final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build( final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build(
List.of( List.of(
managedAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false), registeredAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false),
orphanAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)), unregisteredAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)),
projectRoot, projectRoot,
"", "",
EnumSet.of(AssetNavigatorFilter.MANAGED)); EnumSet.of(AssetNavigatorFilter.REGISTERED));
assertEquals(1, projection.visibleAssetCount()); assertEquals(1, projection.visibleAssetCount());
assertEquals("ui_atlas", projection.groups().getFirst().assets().getFirst().assetName()); assertEquals("ui_atlas", projection.groups().getFirst().assets().getFirst().assetName());
@ -47,12 +47,12 @@ final class AssetNavigatorProjectionBuilderTest {
final Path assetsRoot = projectRoot.resolve("assets"); final Path assetsRoot = projectRoot.resolve("assets");
final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build( final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build(
List.of( List.of(
managedAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, true), registeredAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, true),
managedAsset(2, "bg_tiles", "image_bank", assetsRoot.resolve("bg/tiles"), true, false), registeredAsset(2, "bg_tiles", "image_bank", assetsRoot.resolve("bg/tiles"), true, false),
managedAsset(3, "voice_bank", "sound_bank", assetsRoot.resolve("audio/voice"), false, true)), registeredAsset(3, "voice_bank", "sound_bank", assetsRoot.resolve("audio/voice"), false, true)),
projectRoot, projectRoot,
"", "",
EnumSet.of(AssetNavigatorFilter.MANAGED, AssetNavigatorFilter.PRELOAD, AssetNavigatorFilter.DIAGNOSTICS)); EnumSet.of(AssetNavigatorFilter.REGISTERED, AssetNavigatorFilter.PRELOAD, AssetNavigatorFilter.DIAGNOSTICS));
assertEquals(1, projection.visibleAssetCount()); assertEquals(1, projection.visibleAssetCount());
assertEquals("ui_atlas", projection.groups().getFirst().assets().getFirst().assetName()); 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 projectRoot = Path.of("/tmp/project");
final Path assetsRoot = projectRoot.resolve("assets"); final Path assetsRoot = projectRoot.resolve("assets");
final List<AssetWorkspaceAssetSummary> assets = List.of( final List<AssetWorkspaceAssetSummary> assets = List.of(
managedAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false), registeredAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false),
orphanAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)); unregisteredAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false));
final AssetNavigatorProjection byName = AssetNavigatorProjectionBuilder.build( final AssetNavigatorProjection byName = AssetNavigatorProjectionBuilder.build(
assets, assets,
@ -83,7 +83,7 @@ final class AssetNavigatorProjectionBuilderTest {
assertEquals("menu_sounds", byPath.groups().getFirst().assets().getFirst().assetName()); assertEquals("menu_sounds", byPath.groups().getFirst().assets().getFirst().assetName());
} }
private AssetWorkspaceAssetSummary managedAsset( private AssetWorkspaceAssetSummary registeredAsset(
int assetId, int assetId,
String name, String name,
String family, String family,
@ -93,7 +93,8 @@ final class AssetNavigatorProjectionBuilderTest {
return new AssetWorkspaceAssetSummary( return new AssetWorkspaceAssetSummary(
new AssetWorkspaceSelectionKey.ManagedAsset(assetId), new AssetWorkspaceSelectionKey.ManagedAsset(assetId),
name, name,
AssetWorkspaceAssetState.MANAGED, AssetWorkspaceAssetState.REGISTERED,
AssetWorkspaceBuildParticipation.INCLUDED,
assetId, assetId,
family, family,
root, root,
@ -101,7 +102,7 @@ final class AssetNavigatorProjectionBuilderTest {
hasDiagnostics); hasDiagnostics);
} }
private AssetWorkspaceAssetSummary orphanAsset( private AssetWorkspaceAssetSummary unregisteredAsset(
String name, String name,
String family, String family,
Path root, Path root,
@ -110,7 +111,8 @@ final class AssetNavigatorProjectionBuilderTest {
return new AssetWorkspaceAssetSummary( return new AssetWorkspaceAssetSummary(
new AssetWorkspaceSelectionKey.OrphanAsset(root), new AssetWorkspaceSelectionKey.OrphanAsset(root),
name, name,
AssetWorkspaceAssetState.ORPHAN, AssetWorkspaceAssetState.UNREGISTERED,
AssetWorkspaceBuildParticipation.EXCLUDED,
null, null,
family, family,
root, root,

View File

@ -9,40 +9,58 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
final class AssetWorkspaceActionSetBuilderTest { final class AssetWorkspaceActionSetBuilderTest {
@Test @Test
void managedAssetsExposeDoctorBuildAndSensitiveMutations() { void includedRegisteredAssetsExposeOnlySensitiveAssetMutations() {
final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(new AssetWorkspaceAssetSummary( final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(new AssetWorkspaceAssetSummary(
new AssetWorkspaceSelectionKey.ManagedAsset(42), new AssetWorkspaceSelectionKey.ManagedAsset(42),
"ui_atlas", "ui_atlas",
AssetWorkspaceAssetState.MANAGED, AssetWorkspaceAssetState.REGISTERED,
AssetWorkspaceBuildParticipation.INCLUDED,
42, 42,
"image_bank", "image_bank",
Path.of("/tmp/assets/ui_atlas"), Path.of("/tmp/assets/ui_atlas"),
true, true,
false)); false));
assertEquals(List.of(AssetWorkspaceAction.DOCTOR, AssetWorkspaceAction.BUILD), actionSet.primaryActions()); assertEquals(List.of(), actionSet.primaryActions());
assertEquals( assertEquals(
List.of( List.of(
AssetWorkspaceAction.FORGET, AssetWorkspaceAction.EXCLUDE_FROM_BUILD,
AssetWorkspaceAction.QUARANTINE,
AssetWorkspaceAction.RELOCATE, AssetWorkspaceAction.RELOCATE,
AssetWorkspaceAction.REMOVE), AssetWorkspaceAction.REMOVE),
actionSet.sensitiveActions()); actionSet.sensitiveActions());
} }
@Test @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( final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(new AssetWorkspaceAssetSummary(
new AssetWorkspaceSelectionKey.OrphanAsset(Path.of("/tmp/assets/ui_sounds")), new AssetWorkspaceSelectionKey.OrphanAsset(Path.of("/tmp/assets/ui_sounds")),
"ui_sounds", "ui_sounds",
AssetWorkspaceAssetState.ORPHAN, AssetWorkspaceAssetState.UNREGISTERED,
AssetWorkspaceBuildParticipation.EXCLUDED,
null, null,
"sound_bank", "sound_bank",
Path.of("/tmp/assets/ui_sounds"), Path.of("/tmp/assets/ui_sounds"),
false, false,
false)); false));
assertEquals(List.of(AssetWorkspaceAction.ADOPT, AssetWorkspaceAction.REGISTER), actionSet.primaryActions()); assertEquals(List.of(AssetWorkspaceAction.REGISTER), actionSet.primaryActions());
assertEquals(List.of(AssetWorkspaceAction.QUARANTINE, AssetWorkspaceAction.RELOCATE), actionSet.sensitiveActions()); assertEquals(List.of(AssetWorkspaceAction.RELOCATE), actionSet.sensitiveActions());
} }
} }

View File

@ -13,7 +13,8 @@ final class AssetWorkspaceMutationImpactViewModelTest {
final AssetWorkspaceAssetSummary asset = new AssetWorkspaceAssetSummary( final AssetWorkspaceAssetSummary asset = new AssetWorkspaceAssetSummary(
new AssetWorkspaceSelectionKey.ManagedAsset(1), new AssetWorkspaceSelectionKey.ManagedAsset(1),
"ui_atlas", "ui_atlas",
AssetWorkspaceAssetState.MANAGED, AssetWorkspaceAssetState.REGISTERED,
AssetWorkspaceBuildParticipation.INCLUDED,
1, 1,
"image_bank", "image_bank",
Path.of("/tmp/assets/ui/atlas"), Path.of("/tmp/assets/ui/atlas"),

View File

@ -9,7 +9,7 @@ import static org.junit.jupiter.api.Assertions.*;
final class AssetWorkspaceStateTest { final class AssetWorkspaceStateTest {
@Test @Test
void preservesManagedSelectionByAssetIdAcrossRefresh() { void preservesRegisteredSelectionByAssetIdAcrossRefresh() {
final AssetWorkspaceSelectionKey.ManagedAsset selected = new AssetWorkspaceSelectionKey.ManagedAsset(42); final AssetWorkspaceSelectionKey.ManagedAsset selected = new AssetWorkspaceSelectionKey.ManagedAsset(42);
final List<AssetWorkspaceAssetSummary> assets = List.of( final List<AssetWorkspaceAssetSummary> assets = List.of(
managedAsset(42, "ui_atlas", Path.of("/tmp/assets/ui_atlas")), managedAsset(42, "ui_atlas", Path.of("/tmp/assets/ui_atlas")),
@ -24,17 +24,17 @@ final class AssetWorkspaceStateTest {
} }
@Test @Test
void preservesOrphanSelectionByAssetRootAcrossRefresh() { void preservesUnregisteredSelectionByAssetRootAcrossRefresh() {
final Path orphanRoot = Path.of("/tmp/assets/orphan_bank"); final Path unregisteredRoot = Path.of("/tmp/assets/orphan_bank");
final AssetWorkspaceSelectionKey.OrphanAsset selected = new AssetWorkspaceSelectionKey.OrphanAsset(orphanRoot); final AssetWorkspaceSelectionKey.OrphanAsset selected = new AssetWorkspaceSelectionKey.OrphanAsset(unregisteredRoot);
final List<AssetWorkspaceAssetSummary> assets = List.of( final List<AssetWorkspaceAssetSummary> assets = List.of(
orphanAsset("orphan_bank", orphanRoot), unregisteredAsset("orphan_bank", unregisteredRoot),
orphanAsset("other_bank", Path.of("/tmp/assets/other_bank"))); unregisteredAsset("other_bank", Path.of("/tmp/assets/other_bank")));
final AssetWorkspaceState state = AssetWorkspaceState.ready(assets, selected); final AssetWorkspaceState state = AssetWorkspaceState.ready(assets, selected);
assertEquals(selected, state.selectedKey()); assertEquals(selected, state.selectedKey());
assertEquals(orphanRoot.toAbsolutePath().normalize(), state.selectedAsset().orElseThrow().assetRoot()); assertEquals(unregisteredRoot.toAbsolutePath().normalize(), state.selectedAsset().orElseThrow().assetRoot());
} }
@Test @Test
@ -62,7 +62,8 @@ final class AssetWorkspaceStateTest {
return new AssetWorkspaceAssetSummary( return new AssetWorkspaceAssetSummary(
new AssetWorkspaceSelectionKey.ManagedAsset(assetId), new AssetWorkspaceSelectionKey.ManagedAsset(assetId),
name, name,
AssetWorkspaceAssetState.MANAGED, AssetWorkspaceAssetState.REGISTERED,
AssetWorkspaceBuildParticipation.INCLUDED,
assetId, assetId,
"image_bank", "image_bank",
root, root,
@ -70,11 +71,12 @@ final class AssetWorkspaceStateTest {
false); false);
} }
private AssetWorkspaceAssetSummary orphanAsset(String name, Path root) { private AssetWorkspaceAssetSummary unregisteredAsset(String name, Path root) {
return new AssetWorkspaceAssetSummary( return new AssetWorkspaceAssetSummary(
new AssetWorkspaceSelectionKey.OrphanAsset(root), new AssetWorkspaceSelectionKey.OrphanAsset(root),
name, name,
AssetWorkspaceAssetState.ORPHAN, AssetWorkspaceAssetState.UNREGISTERED,
AssetWorkspaceBuildParticipation.EXCLUDED,
null, null,
"image_bank", "image_bank",
root, root,

View File

@ -14,51 +14,42 @@ final class FileSystemAssetWorkspaceMutationServiceTest {
Path tempDir; Path tempDir;
@Test @Test
void previewQuarantineForManagedAssetShowsRegistryAndWorkspaceImpact() throws Exception { void previewRelocateForManagedAssetIsHighRiskAndPreservesIdentityContract() throws Exception {
final Path projectRoot = createManagedAssetProject(); final Path projectRoot = createManagedAssetProject();
final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService();
final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot); 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()); assertTrue(preview.canApply());
assertFalse(preview.highRisk()); assertTrue(preview.highRisk());
assertNotNull(preview.targetAssetRoot()); assertEquals(customTarget.toAbsolutePath().normalize(), preview.targetAssetRoot());
assertEquals(1, preview.changes().stream().filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.REGISTRY).count()); assertTrue(preview.changes().stream().anyMatch(change ->
assertEquals(1, preview.changes().stream().filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.WORKSPACE).count()); change.scope() == AssetWorkspaceMutationChangeScope.REGISTRY && change.verb().equals("UPDATE")));
assertTrue(preview.changes().stream().anyMatch(change ->
change.scope() == AssetWorkspaceMutationChangeScope.WORKSPACE && change.verb().equals("MOVE")));
} }
@Test @Test
void applyQuarantineMovesAssetAndRemovesRegistryEntry() throws Exception { void applyExcludeFromBuildPreservesRegistrationAndUpdatesRegistryState() throws Exception {
final Path projectRoot = createManagedAssetProject(); final Path projectRoot = createManagedAssetProject();
final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService();
final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot); final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot);
final ProjectReference project = project("Main", 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); 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")); final String registryJson = Files.readString(projectRoot.resolve("assets/.prometeu/index.json"));
assertFalse(registryJson.contains("\"root\" : \"ui/atlas\"")); assertTrue(registryJson.contains("\"asset_id\" : 1"));
} assertTrue(registryJson.contains("\"included_in_build\" : false"));
@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")));
} }
@Test @Test
@ -67,15 +58,33 @@ final class FileSystemAssetWorkspaceMutationServiceTest {
final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService();
final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot); final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot);
final ProjectReference project = project("Main", 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); service.apply(project, preview);
assertFalse(Files.exists(asset.assetRoot())); 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")); final String registryJson = Files.readString(projectRoot.resolve("assets/.prometeu/index.json"));
assertTrue(registryJson.contains("\"asset_id\" : 1")); 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 @Test
@ -85,14 +94,15 @@ final class FileSystemAssetWorkspaceMutationServiceTest {
final AssetWorkspaceAssetSummary asset = new AssetWorkspaceAssetSummary( final AssetWorkspaceAssetSummary asset = new AssetWorkspaceAssetSummary(
new AssetWorkspaceSelectionKey.OrphanAsset(projectRoot.resolve("assets/missing")), new AssetWorkspaceSelectionKey.OrphanAsset(projectRoot.resolve("assets/missing")),
"missing_asset", "missing_asset",
AssetWorkspaceAssetState.ORPHAN, AssetWorkspaceAssetState.UNREGISTERED,
AssetWorkspaceBuildParticipation.EXCLUDED,
null, null,
"unknown", "unknown",
projectRoot.resolve("assets/missing"), projectRoot.resolve("assets/missing"),
false, false,
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.canApply());
assertFalse(preview.blockers().isEmpty()); assertFalse(preview.blockers().isEmpty());
@ -118,7 +128,8 @@ final class FileSystemAssetWorkspaceMutationServiceTest {
{ {
"asset_id": 1, "asset_id": 1,
"asset_uuid": "uuid-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( return new AssetWorkspaceAssetSummary(
new AssetWorkspaceSelectionKey.ManagedAsset(1), new AssetWorkspaceSelectionKey.ManagedAsset(1),
"ui_atlas", "ui_atlas",
AssetWorkspaceAssetState.MANAGED, AssetWorkspaceAssetState.REGISTERED,
AssetWorkspaceBuildParticipation.INCLUDED,
1, 1,
"image_bank", "image_bank",
projectRoot.resolve("assets/ui/atlas"), projectRoot.resolve("assets/ui/atlas"),

View File

@ -25,7 +25,7 @@ final class FileSystemAssetWorkspaceServiceTest {
} }
@Test @Test
void marksRegistryRootsAsManagedAndUnregisteredAnchorsAsOrphan() throws Exception { void marksRegistryRootsAsRegisteredAndUnregisteredAnchorsAsExcluded() throws Exception {
final Path projectRoot = tempDir.resolve("main"); final Path projectRoot = tempDir.resolve("main");
final Path assetsRoot = projectRoot.resolve("assets"); final Path assetsRoot = projectRoot.resolve("assets");
final Path managedRoot = assetsRoot.resolve("ui").resolve("atlas"); final Path managedRoot = assetsRoot.resolve("ui").resolve("atlas");
@ -66,21 +66,23 @@ final class FileSystemAssetWorkspaceServiceTest {
final AssetWorkspaceSnapshot snapshot = service.loadWorkspace(project("Main", projectRoot)); final AssetWorkspaceSnapshot snapshot = service.loadWorkspace(project("Main", projectRoot));
assertEquals(2, snapshot.assets().size()); assertEquals(2, snapshot.assets().size());
final AssetWorkspaceAssetSummary managed = snapshot.assets().stream() final AssetWorkspaceAssetSummary registered = snapshot.assets().stream()
.filter(asset -> asset.assetName().equals("ui_atlas")) .filter(asset -> asset.assetName().equals("ui_atlas"))
.findFirst() .findFirst()
.orElseThrow(); .orElseThrow();
final AssetWorkspaceAssetSummary orphan = snapshot.assets().stream() final AssetWorkspaceAssetSummary unregistered = snapshot.assets().stream()
.filter(asset -> asset.assetName().equals("ui_sounds")) .filter(asset -> asset.assetName().equals("ui_sounds"))
.findFirst() .findFirst()
.orElseThrow(); .orElseThrow();
assertEquals(AssetWorkspaceAssetState.MANAGED, managed.state()); assertEquals(AssetWorkspaceAssetState.REGISTERED, registered.state());
assertEquals(1, managed.assetId()); assertEquals(AssetWorkspaceBuildParticipation.INCLUDED, registered.buildParticipation());
assertTrue(managed.preload()); assertEquals(1, registered.assetId());
assertTrue(registered.preload());
assertEquals(AssetWorkspaceAssetState.ORPHAN, orphan.state()); assertEquals(AssetWorkspaceAssetState.UNREGISTERED, unregistered.state());
assertEquals(null, orphan.assetId()); assertEquals(AssetWorkspaceBuildParticipation.EXCLUDED, unregistered.buildParticipation());
assertEquals(null, unregistered.assetId());
} }
@Test @Test

View File

@ -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"));
}
}

View File

@ -37,7 +37,8 @@ final class PackerBackedAssetWorkspaceMutationServiceTest {
final AssetWorkspaceMutationPreview preview = service.preview( final AssetWorkspaceMutationPreview preview = service.preview(
project("Main", projectRoot), project("Main", projectRoot),
managedAsset(projectRoot), managedAsset(projectRoot),
AssetWorkspaceAction.QUARANTINE); AssetWorkspaceAction.RELOCATE,
projectRoot.resolve("assets/reorganized/atlas"));
assertEquals(1, previewEvents.size()); assertEquals(1, previewEvents.size());
assertTrue(preview.canApply()); assertTrue(preview.canApply());
@ -53,16 +54,19 @@ final class PackerBackedAssetWorkspaceMutationServiceTest {
final List<StudioAssetsMutationAppliedEvent> appliedEvents = new ArrayList<>(); final List<StudioAssetsMutationAppliedEvent> appliedEvents = new ArrayList<>();
globalBus.subscribe(StudioAssetsMutationAppliedEvent.class, appliedEvents::add); globalBus.subscribe(StudioAssetsMutationAppliedEvent.class, appliedEvents::add);
final PackerBackedAssetWorkspaceMutationService service = service(globalBus); final PackerBackedAssetWorkspaceMutationService service = service(globalBus);
final Path customTarget = projectRoot.resolve("assets/reorganized/atlas");
final AssetWorkspaceMutationPreview preview = service.preview( final AssetWorkspaceMutationPreview preview = service.preview(
project("Main", projectRoot), project("Main", projectRoot),
managedAsset(projectRoot), managedAsset(projectRoot),
AssetWorkspaceAction.RELOCATE); AssetWorkspaceAction.RELOCATE,
customTarget);
service.apply(project("Main", projectRoot), preview); service.apply(project("Main", projectRoot), preview);
assertEquals(1, appliedEvents.size()); assertEquals(1, appliedEvents.size());
assertEquals(AssetWorkspaceAction.RELOCATE, appliedEvents.getFirst().action()); assertEquals(AssetWorkspaceAction.RELOCATE, appliedEvents.getFirst().action());
assertTrue(Files.isDirectory(preview.targetAssetRoot())); assertEquals(customTarget.toAbsolutePath().normalize(), preview.targetAssetRoot());
assertTrue(Files.isDirectory(customTarget));
} }
@Test @Test
@ -72,10 +76,12 @@ final class PackerBackedAssetWorkspaceMutationServiceTest {
final List<StudioAssetsMutationFailedEvent> failedEvents = new ArrayList<>(); final List<StudioAssetsMutationFailedEvent> failedEvents = new ArrayList<>();
globalBus.subscribe(StudioAssetsMutationFailedEvent.class, failedEvents::add); globalBus.subscribe(StudioAssetsMutationFailedEvent.class, failedEvents::add);
final PackerBackedAssetWorkspaceMutationService service = service(globalBus); final PackerBackedAssetWorkspaceMutationService service = service(globalBus);
final Path customTarget = projectRoot.resolve("assets/reorganized/atlas");
final AssetWorkspaceMutationPreview preview = service.preview( final AssetWorkspaceMutationPreview preview = service.preview(
project("Main", projectRoot), project("Main", projectRoot),
managedAsset(projectRoot), managedAsset(projectRoot),
AssetWorkspaceAction.RELOCATE); AssetWorkspaceAction.RELOCATE,
customTarget);
deleteRecursively(projectRoot.resolve("assets/ui/atlas")); deleteRecursively(projectRoot.resolve("assets/ui/atlas"));
@ -85,6 +91,27 @@ final class PackerBackedAssetWorkspaceMutationServiceTest {
assertFalse(failedEvents.getFirst().message().isBlank()); 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<StudioAssetsMutationPreviewReadyEvent> 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) { private PackerBackedAssetWorkspaceMutationService service(StudioEventBus globalBus) {
return new PackerBackedAssetWorkspaceMutationService( return new PackerBackedAssetWorkspaceMutationService(
new StudioWorkspaceEventBus(WorkspaceId.ASSETS, globalBus), new StudioWorkspaceEventBus(WorkspaceId.ASSETS, globalBus),
@ -126,7 +153,8 @@ final class PackerBackedAssetWorkspaceMutationServiceTest {
return new AssetWorkspaceAssetSummary( return new AssetWorkspaceAssetSummary(
new AssetWorkspaceSelectionKey.ManagedAsset(1), new AssetWorkspaceSelectionKey.ManagedAsset(1),
"ui_atlas", "ui_atlas",
AssetWorkspaceAssetState.MANAGED, AssetWorkspaceAssetState.REGISTERED,
AssetWorkspaceBuildParticipation.INCLUDED,
1, 1,
"image_bank", "image_bank",
projectRoot.resolve("assets/ui/atlas"), projectRoot.resolve("assets/ui/atlas"),

View File

@ -60,8 +60,8 @@ final class PackerBackedAssetWorkspaceServiceTest {
final AssetWorkspaceSnapshot snapshot = service.loadWorkspace(project("Main", projectRoot)); final AssetWorkspaceSnapshot snapshot = service.loadWorkspace(project("Main", projectRoot));
assertEquals(2, snapshot.assets().size()); assertEquals(2, snapshot.assets().size());
assertTrue(snapshot.assets().stream().anyMatch(asset -> asset.state() == AssetWorkspaceAssetState.MANAGED)); assertTrue(snapshot.assets().stream().anyMatch(asset -> asset.state() == AssetWorkspaceAssetState.REGISTERED));
assertTrue(snapshot.assets().stream().anyMatch(asset -> asset.state() == AssetWorkspaceAssetState.ORPHAN)); assertTrue(snapshot.assets().stream().anyMatch(asset -> asset.state() == AssetWorkspaceAssetState.UNREGISTERED));
} }
@Test @Test

View File

@ -69,7 +69,7 @@ final class PackerStudioIntegrationTest {
assertEquals(2, snapshot.assets().size()); assertEquals(2, snapshot.assets().size());
final AssetWorkspaceAssetSummary orphan = snapshot.assets().stream() final AssetWorkspaceAssetSummary orphan = snapshot.assets().stream()
.filter(asset -> asset.state() == AssetWorkspaceAssetState.ORPHAN) .filter(asset -> asset.state() == AssetWorkspaceAssetState.UNREGISTERED)
.findFirst() .findFirst()
.orElseThrow(); .orElseThrow();
final AssetWorkspaceMutationPreview preview = mutationService.preview(project, orphan, AssetWorkspaceAction.REGISTER); final AssetWorkspaceMutationPreview preview = mutationService.preview(project, orphan, AssetWorkspaceAction.REGISTER);

View File

@ -1,9 +1,20 @@
{ {
"schema_version" : 1, "schema_version" : 1,
"next_asset_id" : 2, "next_asset_id" : 9,
"assets" : [ { "assets" : [ {
"asset_id" : 1, "asset_id" : 3,
"asset_uuid" : "67cd978d-cd61-4641-ba9e-98fe4bc039bd", "asset_uuid" : "21953cb8-4101-4790-9e5e-d95f5fbc9b5a",
"root" : "ui/atlas-relocated" "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
} ] } ]
} }

View File

@ -0,0 +1,13 @@
{
"schema_version" : 1,
"name" : "bla",
"type" : "sound_bank",
"inputs" : { },
"output" : {
"format" : "AUDIO/pcm_v1",
"codec" : "RAW"
},
"preload" : {
"enabled" : true
}
}

View File

Before

Width:  |  Height:  |  Size: 137 B

After

Width:  |  Height:  |  Size: 137 B

View File

@ -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
}
}