diff --git a/docs/packer/decisions/Filesystem-First Operational Runtime and Reconcile Boundary Decision.md b/docs/packer/decisions/Filesystem-First Operational Runtime and Reconcile Boundary Decision.md new file mode 100644 index 00000000..78f9ffa2 --- /dev/null +++ b/docs/packer/decisions/Filesystem-First Operational Runtime and Reconcile Boundary Decision.md @@ -0,0 +1,175 @@ +# Filesystem-First Operational Runtime and Reconcile Boundary Decision + +Status: Accepted +Date: 2026-03-15 +Domain Owner: `docs/packer` +Cross-Domain Impact: `docs/studio` + +## Context + +The current packer model already has the right core separation: + +- `assets/.prometeu/index.json` is the authoritative registry/catalog for managed assets; +- each asset root is anchored by `asset.json`; +- `asset.json` is the authoring-side declaration of how that asset is packed; +- Studio is meant to consume packer-owned operational semantics instead of recreating them locally. + +What is still missing is a clear architectural decision for how the packer should behave operationally at runtime. + +The repository now needs an answer to these questions: + +1. should the packer remain a collection of direct filesystem services, recomputing state per request; +2. should the packer become a pure database-style system that displaces the open filesystem workflow; +3. or should it become a filesystem-first operational runtime that maintains an in-memory snapshot while preserving the workspace as the durable authoring surface. + +The wrong answer here would create product friction. + +If the packer behaves like a pure database, it will fight the real creative workflow where developers: + +- edit files with their preferred tools; +- move directories manually when needed; +- inspect and version workspace files directly; +- expect the Studio to help organize work, not imprison it. + +At the same time, if the packer stays purely filesystem-per-call, it will remain too expensive, too incoherent under concurrent use, and too weak as the operational source of truth for Studio. + +## Decision + +The following direction is adopted: + +1. `prometeu-packer` remains `filesystem-first`. +2. The packer becomes a `project-scoped operational runtime`, not a pure database. +3. The packer maintains an in-memory project snapshot for live operational reads and write coordination. +4. The durable authoring workspace on disk remains the final persisted source of truth. +5. The packer owns request/response read and write APIs over that runtime snapshot. +6. Writes execute through a packer-owned project write lane and become durably visible only after commit succeeds. +7. The packer will support background divergence detection between runtime snapshot and filesystem state. +8. Divergence detection must surface reconcile state explicitly; it must not silently invent or hide semantic repairs. +9. Studio remains a frontend consumer of packer responses, commands, and events. +10. When embedded inside Studio, the packer runtime is bootstrapped with a typed event bus reference supplied by the Studio bootstrap container. + +## Adopted Constraints + +### 1. Filesystem-First Authority + +- the asset workspace under `assets/` remains the authoring environment; +- `asset.json` remains the asset-local declaration contract; +- `assets/.prometeu/index.json` remains the authoritative registry/catalog for managed assets; +- the packer runtime snapshot is an operational projection of packer-owned workspace artifacts, not a replacement authoring format. + +### 2. Identity and Declaration Split + +- `asset_id`, `included_in_build`, and registry-managed location tracking remain registry/catalog concerns; +- `asset.json` remains primarily a declaration of the asset contract and packing inputs/outputs; +- `asset.json` may carry the asset-local identity anchor needed for reconcile, specifically `asset_uuid`; +- `asset.json` must not become a dumping ground for transient UI state, cache state, or catalog-derived bookkeeping that belongs in `index.json` or other packer-owned control files. + +### 3. Snapshot-Backed Read Semantics + +- normal read APIs should serve from a coherent in-memory project snapshot; +- the packer must not require a full workspace recomputation for every normal read call once the runtime is active; +- concurrent reads may proceed when they observe a coherent snapshot generation; +- reads must not expose torn intermediate write state as committed truth. + +### 4. Packer-Owned Write Execution + +- write operations on one project are coordinated by the packer, not by caller timing; +- the baseline policy remains a single-writer semantic lane per project; +- write intent may compute previews before final commit when safe; +- final apply/commit remains serialized per project; +- successful durable commit defines post-write visibility. + +### 5. Durable Commit Boundary + +- the packer runtime may stage write changes in memory before disk commit; +- partially applied intermediate state must not be presented as durably committed truth; +- commit failure must leave the project in an explicitly diagnosable condition; +- recovery and reconcile rules must be designed as packer behavior, not delegated to Studio guesswork. + +### 6. Divergence Detection and Reconcile + +- a future packer-owned background observation path may detect divergence between runtime snapshot and filesystem state; +- this path exists to keep the runtime honest with respect to manual or external edits; +- divergence must result in explicit runtime state such as stale, diverged, reconciling, or failed; +- the packer must not silently rewrite user content just because divergence was observed; +- reconcile is an explicit packer-owned behavior and must preserve causality in events and responses. + +### 7. Studio Consumer Boundary + +- Studio consumes packer read responses, write outcomes, and packer-native lifecycle events; +- Studio may render stale/diverged/reconciling states, but must not invent packer-side reconcile semantics; +- Studio must not become the owner of filesystem-vs-snapshot conflict resolution; +- the Studio integration contract should remain command-oriented and event-driven. + +### 8. Embedded Event Bus Bootstrap + +- when the packer is embedded inside Studio, it must receive a typed event bus reference during bootstrap instead of creating an unrelated local event system; +- that reference is used for packer event publication and any packer-side subscription/unsubscription behavior needed by the embedded runtime; +- in Studio, the owner of that typed event bus reference is the application container; +- the Studio `Container` must be initialized as part of Studio boot before packer-backed workspaces or adapters start consuming packer services. + +## Why This Direction Was Chosen + +- It preserves the developer's open creative workflow around normal files and directories. +- It keeps the packer useful as an organizing and coordinating system instead of turning it into an opaque silo. +- It allows fast and coherent reads for Studio and tooling. +- It gives write coordination, commit visibility, and operational causality one owner. +- It creates a realistic path toward future background divergence detection without pretending that the filesystem stopped mattering. + +## Explicit Non-Decisions + +This decision does not define: + +- the final class/module names of the packer runtime implementation; +- the final executor/thread primitive used internally; +- the exact event vocabulary for all future reconcile states; +- the final automatic-vs-manual reconcile policy for each drift scenario; +- a watch-service or daemon transport implementation; +- remote or multi-process synchronization; +- a pure database persistence model. + +## Implications + +- the packer runtime track must preserve `index.json` plus `asset.json` as the durable workspace artifacts; +- `asset.json` should evolve carefully to support local identity anchoring without absorbing catalog-only fields; +- the runtime snapshot should be described as an operational cache/projection with authority for live service behavior, not as a new authoring truth; +- mutation, doctor, build, and read services should converge on the same runtime state model; +- future drift detection work must be designed together with diagnostics, refresh, and reconcile surfaces in Studio. +- embedded Studio wiring must preserve one container-owned typed event bus reference instead of fragmented packer-local bus ownership. + +## Propagation Targets + +Specs: + +- [`../specs/2. Workspace, Registry, and Asset Identity Specification.md`](../specs/2.%20Workspace,%20Registry,%20and%20Asset%20Identity%20Specification.md) +- [`../specs/3. Asset Declaration and Virtual Asset Contract Specification.md`](../specs/3.%20Asset%20Declaration%20and%20Virtual%20Asset%20Contract%20Specification.md) +- [`../specs/5. Diagnostics, Operations, and Studio Integration Specification.md`](../specs/5.%20Diagnostics,%20Operations,%20and%20Studio%20Integration%20Specification.md) +- [`../specs/6. Versioning, Migration, and Trust Model Specification.md`](../specs/6.%20Versioning,%20Migration,%20and%20Trust%20Model%20Specification.md) + +Plans: + +- [`../pull-requests/PR-11-packer-runtime-restructure-snapshot-authority-and-durable-commit.md`](../pull-requests/PR-11-packer-runtime-restructure-snapshot-authority-and-durable-commit.md) + +Cross-domain references: + +- [`../../studio/specs/4. Assets Workspace Specification.md`](../../studio/specs/4.%20Assets%20Workspace%20Specification.md) + +Implementation surfaces: + +- future packer project-runtime/bootstrap code +- snapshot-backed read services +- project write-lane and durable commit pipeline +- drift detection and reconcile state reporting +- Studio packer adapters for stale/diverged/reconciling operational states +- Studio bootstrap/container wiring for the shared typed event bus reference + +## Validation Notes + +This decision is correctly implemented only when all of the following are true: + +- developers may continue to inspect and edit asset workspace files directly; +- packer reads are coherent without requiring full recomputation on each normal request; +- same-project writes remain serialized by the packer; +- Studio does not observe torn committed truth during write activity; +- divergence between runtime snapshot and filesystem state can be detected and surfaced explicitly; +- Studio remains a consumer of packer-owned reconcile and lifecycle semantics rather than inventing them. diff --git a/docs/packer/decisions/README.md b/docs/packer/decisions/README.md index 2b29b47a..57566acf 100644 --- a/docs/packer/decisions/README.md +++ b/docs/packer/decisions/README.md @@ -5,6 +5,7 @@ This directory contains packer decision records. Retained packer decision records: - [`Concurrency, Observability, and Studio Adapter Boundary Decision.md`](./Concurrency,%20Observability,%20and%20Studio%20Adapter%20Boundary%20Decision.md) +- [`Filesystem-First Operational Runtime and Reconcile Boundary Decision.md`](./Filesystem-First%20Operational%20Runtime%20and%20Reconcile%20Boundary%20Decision.md) The first packer decision wave was already consolidated into: diff --git a/docs/packer/pull-requests/PR-11-packer-runtime-restructure-snapshot-authority-and-durable-commit.md b/docs/packer/pull-requests/PR-11-packer-runtime-restructure-snapshot-authority-and-durable-commit.md new file mode 100644 index 00000000..6ca14efc --- /dev/null +++ b/docs/packer/pull-requests/PR-11-packer-runtime-restructure-snapshot-authority-and-durable-commit.md @@ -0,0 +1,194 @@ +# PR-11 Packer Runtime Restructure, Snapshot Authority, and Durable Commit + +Domain Owner: `docs/packer` +Cross-Domain Impact: `docs/studio` + +## Briefing + +The current `prometeu-packer` production track established the packer as the semantic owner of asset state, write semantics, diagnostics, and operational events. + +The next architectural step is to restructure the packer so it behaves like a filesystem-first project-scoped operational runtime for the service surface the Studio actually uses today, rather than a collection of filesystem-per-call services: + +- reads should come from a coherent in-memory snapshot; +- writes should execute through a packer-owned write path; +- state transitions should be coordinated by the packer, not by incidental caller sequencing; +- durable visibility should be defined by commit to disk, not by partially observed intermediate filesystem state; +- Studio should remain a frontend consumer of packer-owned read/write/event contracts. + +This is a service-first re-architecture program, not a cosmetic refactor. +The likely outcome is a substantial internal rewrite of the packer service layer while preserving and tightening the external semantic contract already defined by the packer specs. + +The current wave is intentionally narrow: + +- build only the embedded service runtime needed by Studio asset management; +- remove unused or out-of-scope capabilities from the active code path instead of carrying them forward speculatively; +- reintroduce `doctor`, `build/pack`, and background reconcile only when a later concrete service need justifies them. + +This PR is an umbrella planning artifact only. +It does not authorize direct implementation work by itself. + +## Objective + +Define and execute a family of packer PRs that turns the packer into a project-scoped runtime with: + +- explicit read and write APIs; +- a coherent in-memory project snapshot; +- packer-owned threading for state write coordination; +- durable commit to workspace files as the persistence boundary; +- causality-preserving events for Studio and other tooling consumers; +- an aggressively reduced active surface focused on the service capabilities currently consumed by Studio. + +Communication model baseline: + +- request/response is the primary contract for queries and commands; +- events are the primary contract for asynchronous lifecycle, progress, divergence, and terminal operation reporting; +- synchronous command entrypoints may return a `Future` directly when the caller needs an operation handle for later completion; +- long-running command completion may still be observed through causality-preserving events correlated to that operation; +- the packer should not use events as a replacement for normal query/command response semantics. + +## Dependencies + +- [`../specs/1. Domain and Artifact Boundary Specification.md`](../specs/1.%20Domain%20and%20Artifact%20Boundary%20Specification.md) +- [`../specs/2. Workspace, Registry, and Asset Identity Specification.md`](../specs/2.%20Workspace,%20Registry,%20and%20Asset%20Identity%20Specification.md) +- [`../specs/5. Diagnostics, Operations, and Studio Integration Specification.md`](../specs/5.%20Diagnostics,%20Operations,%20and%20Studio%20Integration%20Specification.md) +- [`../decisions/Concurrency, Observability, and Studio Adapter Boundary Decision.md`](../decisions/Concurrency,%20Observability,%20and%20Studio%20Adapter%20Boundary%20Decision.md) +- cross-domain reference: [`../../studio/specs/4. Assets Workspace Specification.md`](../../studio/specs/4.%20Assets%20Workspace%20Specification.md) + +Decision baseline already in place: + +- [`../decisions/Filesystem-First Operational Runtime and Reconcile Boundary Decision.md`](../decisions/Filesystem-First%20Operational%20Runtime%20and%20Reconcile%20Boundary%20Decision.md) + +This PR is the umbrella execution plan for that direction. +It is not a substitute for that decision record and it is not itself an implementation PR. + +## Scope + +- lock the architectural target and implementation decomposition for the runtime-restructure wave +- perform cleanup and active-surface reduction before runtime work begins +- define a packer-internal project runtime model that owns one coherent state snapshot per active project +- define packer-owned read APIs that serve data from the runtime snapshot instead of recomputing the full model from disk for each call +- define a packer-owned write lane that executes on packer-controlled threading rather than caller-controlled sequencing +- define the durable commit model from in-memory state to workspace files under `assets/` and `assets/.prometeu/` +- define snapshot refresh/bootstrap/recovery behavior +- define embedded-host bootstrap rules for supplying the explicit `PackerEventSink` used by packer event publication, with host-side bridging to any shared typed event bus +- define the query/command versus event boundary for Studio integration +- define how synchronous command entrypoints expose `Future`-based completion to callers that need direct operation handles +- migrate only the service surface currently used by Studio asset management onto the runtime model +- remove or retire implementation paths that are not used by that active service wave +- preserve Studio as a consumer of packer responses and events, not an owner of packer semantics +- retire the current filesystem-per-call service style once the runtime-backed path is stable + +## Non-Goals + +- no direct code implementation inside `PR-11` +- no direct rollout of one monolithic runtime rewrite under one follow-up change +- no redesign of Studio workspace UX +- no remote daemon or IPC transport in this wave +- no distributed or multi-process transactional protocol +- no final watch-service design for external filesystem edits +- no `doctor` implementation in this wave +- no `build/pack` implementation in this wave +- no background reconcile implementation in this wave +- no silent semantic changes to asset identity, registry authority, or write behavior already defined in packer specs +- no replacement of the packer event model with Studio-local orchestration + +## Execution Method + +This work must be executed as a family of follow-up PRs. +`PR-11` freezes the target architecture and the decomposition logic, but implementation starts only in later PR documents. + +The follow-up implementation PR family is: + +1. [`PR-12-cleanup-and-unused-surface-removal-before-runtime-service-wave.md`](./PR-12-cleanup-and-unused-surface-removal-before-runtime-service-wave.md) +2. [`PR-13-embedded-bootstrap-container-owned-event-bus-and-packer-composition-root.md`](./PR-13-embedded-bootstrap-container-owned-event-bus-and-packer-composition-root.md) +3. [`PR-14-project-runtime-core-snapshot-model-and-lifecycle.md`](./PR-14-project-runtime-core-snapshot-model-and-lifecycle.md) +4. [`PR-15-snapshot-backed-asset-query-services.md`](./PR-15-snapshot-backed-asset-query-services.md) +5. [`PR-16-write-lane-command-completion-and-used-write-services.md`](./PR-16-write-lane-command-completion-and-used-write-services.md) +6. [`PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md`](./PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md) +7. [`PR-18-legacy-service-retirement-and-regression-hardening.md`](./PR-18-legacy-service-retirement-and-regression-hardening.md) + +Each follow-up PR should remain granular enough to: + +- have a narrow acceptance surface; +- carry its own tests and rollback story; +- avoid mixing cleanup, bootstrap, runtime-core, UI-adapter, and deferred capability concerns in one code change. + +Wave discipline for all follow-up PRs: + +- remove code that is not used by the active Studio-facing service wave instead of preserving speculative extension points; +- do not reintroduce `doctor`, `build/pack`, or background reconcile as placeholders; +- add capabilities later only when the active Studio integration requires them. + +Deferred from the current wave: + +- `doctor` +- `build/pack` +- background reconcile/diff observer + +Those capabilities should be reintroduced only when the active service wave needs them. + +## Acceptance Criteria + +- `PR-11` remains an umbrella plan rather than a direct implementation vehicle +- the follow-up implementation family is clear enough that later PRs can be opened without reopening the architecture debate +- the packer has an explicit project-scoped runtime authority model +- read operations observe coherent snapshot state +- write operations are executed through packer-owned coordination on packer-controlled threading +- durable visibility is defined by successful commit, not by partially observed intermediate filesystem state +- same-project write conflicts cannot commit concurrently +- same-project read/write interaction does not expose torn committed truth +- asset identity, registry authority, and write semantics remain consistent with existing packer specs +- the active implementation surface contains only the service capabilities currently used by Studio +- unused or out-of-scope legacy capability paths are removed instead of lingering in parallel +- Studio consumes packer read/write/event APIs as a frontend consumer and does not regain semantic ownership +- request/response remains the primary query/command contract while events remain the asynchronous observability contract +- synchronous command APIs may expose `Future` completion directly without collapsing the event lane into ad hoc RPC polling +- event ordering and `operation_id` causality remain valid through the restructured runtime +- the packer keeps `PackerEventSink` as its publication boundary instead of depending directly on host event bus types +- embedded hosts may bridge `PackerEventSink` into a container-owned typed event bus, but the packer API must not normalize a silent `noop` default as production bootstrap behavior + +## Validation + +- snapshot bootstrap tests for projects with valid, invalid, and partially broken asset workspaces +- read coherence tests under concurrent read pressure +- write serialization tests for same-project conflicting writes +- failure and recovery tests for interrupted durable commit paths +- write-path regression tests on top of the runtime core for the commands currently used by Studio +- cleanup validation proving that inactive `doctor`, `build/pack`, and background reconcile implementation paths are no longer part of the active wave +- event ordering and terminal lifecycle tests through the Studio adapter path +- Studio smoke validation for: + - asset listing + - details loading + - staged writes + - relocation + - refresh after packer-owned operations +- bootstrap validation that Studio initializes the container-owned typed event bus before packer-backed runtime use + +## Risks and Rollback + +- this program may expose that the current service boundaries are too filesystem-centric to preserve cleanly +- removing out-of-scope capabilities now may require later reintroduction work when those capabilities become necessary again +- external filesystem edits during runtime lifetime are not fully solved by this plan and must not be hidden as if they were +- if runtime-backed services prove too invasive, rollback should preserve the current stable service contracts while isolating the runtime work behind new internal packages until the migration is complete + +## Affected Artifacts + +- `docs/packer/decisions/**` +- `docs/packer/pull-requests/**` +- `docs/packer/decisions/Filesystem-First Operational Runtime and Reconcile Boundary Decision.md` +- `docs/packer/specs/1. Domain and Artifact Boundary Specification.md` +- `docs/packer/specs/2. Workspace, Registry, and Asset Identity Specification.md` +- `docs/packer/specs/3. Asset Declaration and Virtual Asset Contract Specification.md` +- `docs/packer/specs/5. Diagnostics, Operations, and Studio Integration Specification.md` +- `docs/packer/specs/6. Versioning, Migration, and Trust Model Specification.md` +- `docs/studio/specs/2. Studio UI Foundations Specification.md` +- `docs/studio/specs/4. Assets Workspace Specification.md` +- `prometeu-packer/src/main/java/p/packer/**` +- `prometeu-packer/src/test/java/p/packer/**` +- `prometeu-studio/**` integration adapter and smoke coverage + +## Suggested Next Step + +Do not start code execution directly from this plan. + +The next correct step is to derive granular implementation PRs from `PR-11`, each scoped to one execution front of the runtime-restructure wave. diff --git a/docs/packer/pull-requests/PR-12-cleanup-and-unused-surface-removal-before-runtime-service-wave.md b/docs/packer/pull-requests/PR-12-cleanup-and-unused-surface-removal-before-runtime-service-wave.md new file mode 100644 index 00000000..518f03e2 --- /dev/null +++ b/docs/packer/pull-requests/PR-12-cleanup-and-unused-surface-removal-before-runtime-service-wave.md @@ -0,0 +1,74 @@ +# PR-12 Cleanup and Unused Surface Removal Before the Runtime Service Wave + +Domain Owner: `docs/packer` +Cross-Domain Impact: `docs/studio` + +## Briefing + +Before introducing the runtime service wave, the current packer code should be reduced and cleaned so the next PRs are not built on top of contradictory seams. + +The current code still mixes: + +- implicit concrete instantiation inside services; +- filesystem-per-call orchestration; +- service boundaries that do not match the desired runtime model; +- inactive or out-of-scope capabilities that are not part of the immediate Studio-driven service wave. + +This PR creates the cleanup baseline. + +## Objective + +Remove unused/out-of-scope packer surfaces, align code with the current specs, and prepare a smaller active service boundary for the runtime implementation track. + +## Dependencies + +- [`./PR-11-packer-runtime-restructure-snapshot-authority-and-durable-commit.md`](./PR-11-packer-runtime-restructure-snapshot-authority-and-durable-commit.md) +- [`../decisions/Filesystem-First Operational Runtime and Reconcile Boundary Decision.md`](../decisions/Filesystem-First%20Operational%20Runtime%20and%20Reconcile%20Boundary%20Decision.md) +- [`../specs/2. Workspace, Registry, and Asset Identity Specification.md`](../specs/2.%20Workspace,%20Registry,%20and%20Asset%20Identity%20Specification.md) +- [`../specs/3. Asset Declaration and Virtual Asset Contract Specification.md`](../specs/3.%20Asset%20Declaration%20and%20Virtual%20Asset%20Contract%20Specification.md) +- [`../specs/5. Diagnostics, Operations, and Studio Integration Specification.md`](../specs/5.%20Diagnostics,%20Operations,%20and%20Studio%20Integration%20Specification.md) + +## Scope + +- align `asset.json` handling with the current spec baseline, including `asset_uuid` +- remove inactive `doctor`, `build/pack`, and reconcile-oriented implementation paths from the active runtime-service wave +- remove concrete default instantiation patterns that hide composition ownership +- simplify the active service surface to what the current Studio integration actually needs +- remove code that is not being used for the immediate service-only wave +- correct service contract seams that currently mix read-oriented and mutation-oriented responsibilities in contradictory ways + +## Non-Goals + +- no runtime snapshot yet +- no write lane yet +- no Studio adapter redesign yet +- no reintroduction of doctor/build/reconcile in this wave + +## Execution Method + +1. Align manifest/declaration code with the current spec baseline. +2. Remove inactive service paths that are not part of the current Studio-driven runtime wave. +3. Eliminate implicit composition where services instantiate concrete collaborators by default. +4. Correct active service contracts so the remaining surface matches the Studio-facing runtime plan. +5. Leave the repository with one smaller active surface that the runtime work can replace cleanly. + +## Acceptance Criteria + +- `asset_uuid` is no longer missing from the active declaration path +- inactive `doctor`, `build/pack`, and reconcile implementation paths are removed from the active wave +- concrete service composition is no longer hidden behind broad default constructors in the active path +- contradictory active service contracts are corrected before runtime work starts +- the remaining active surface is focused on the service capabilities the Studio currently needs + +## Validation + +- declaration/parser tests for the manifest baseline +- cleanup tests confirming out-of-scope service paths are no longer part of the active surface +- regression tests confirming the remaining active service surface still works +- smoke validation that Studio-facing packer usage still boots after the cleanup + +## Affected Artifacts + +- `prometeu-packer/src/main/java/p/packer/**` +- `prometeu-packer/src/test/java/p/packer/**` +- `prometeu-studio/**` only where the active service surface changes diff --git a/docs/packer/pull-requests/PR-13-embedded-bootstrap-container-owned-event-bus-and-packer-composition-root.md b/docs/packer/pull-requests/PR-13-embedded-bootstrap-container-owned-event-bus-and-packer-composition-root.md new file mode 100644 index 00000000..6fb5715c --- /dev/null +++ b/docs/packer/pull-requests/PR-13-embedded-bootstrap-container-owned-event-bus-and-packer-composition-root.md @@ -0,0 +1,73 @@ +# PR-13 Embedded Bootstrap, Container-Owned Event Bus, and Packer Composition Root + +Domain Owner: `docs/packer` +Cross-Domain Impact: `docs/studio` + +## Briefing + +After the cleanup baseline, the next step is to make embedded Studio bootstrap explicit and introduce one composition root for the packer. + +This is where the Studio `Container` becomes a contract plus global holder, while the concrete embedded boot and `prometeu-packer-v1` wiring move into the application layer. + +## Objective + +Deliver the embedded bootstrap contract, explicit `PackerEventSink` wiring, and an explicit `prometeu-packer-api` to `prometeu-packer-v1` composition root for Studio embedding. + +## Dependencies + +- [`./PR-12-cleanup-and-unused-surface-removal-before-runtime-service-wave.md`](./PR-12-cleanup-and-unused-surface-removal-before-runtime-service-wave.md) +- [`../specs/5. Diagnostics, Operations, and Studio Integration Specification.md`](../specs/5.%20Diagnostics,%20Operations,%20and%20Studio%20Integration%20Specification.md) +- cross-domain reference: [`../../studio/specs/2. Studio UI Foundations Specification.md`](../../studio/specs/2.%20Studio%20UI%20Foundations%20Specification.md) + +## Scope + +- define the embedded packer bootstrap contract +- define the packer composition root for the active service wave inside the application layer +- keep `prometeu-studio` bound only to `prometeu-packer-api` +- wire the Studio `Container` contract/holder as the owner of the shared typed event bus reference used by the host-side `PackerEventSink` bridge +- ensure application boot installs a `Container` implementation before packer-backed use begins +- make the active embedded runtime entrypoint explicit enough that future capabilities do not depend on hidden constructors or side boot paths + +## Non-Goals + +- no runtime snapshot yet +- no read migration yet +- no write lane yet +- no alternate bootstrap retained for inactive `doctor`, `build/pack`, or reconcile paths + +## Execution Method + +1. Define the explicit packer bootstrap/composition entrypoint. +2. Make the host-provided `PackerEventSink` an explicit dependency for Studio embedding. +3. Refactor Studio `Container` into a contract plus installed global holder. +4. Move concrete packer wiring to the application layer that chooses `prometeu-packer-v1`. +5. Remove remaining ambiguity around packer-local versus container-owned event visibility by bridging `PackerEventSink` into the host bus at the application layer. +5. Remove remaining embedded bootstrap variants that only exist to keep inactive service surfaces alive. + +## Acceptance Criteria + +- the active packer wave has one explicit composition root +- `prometeu-studio` depends only on `prometeu-packer-api` +- the application layer installs the `Container` implementation and chooses `prometeu-packer-v1` +- Studio `Container` owns the shared typed event bus reference through its installed implementation +- the packer composition root receives an explicit `PackerEventSink` rather than reaching directly into host event bus types +- packer-backed work starts only after `Container.install(...)` +- packer publication uses `PackerEventSink`, and the application layer bridges that sink into the container-owned path when embedded in Studio +- no public `PackerEventSink.noop()`-style default is treated as acceptable production bootstrap behavior +- hidden bootstrap paths that only support inactive service surfaces are removed + +## Validation + +- bootstrap tests for the packer composition root +- Studio boot tests for `Container.install(...)` +- integration tests for packer event visibility through the host bridge into the container-owned path + +## Affected Artifacts + +- `prometeu-packer/prometeu-packer-api/src/main/java/p/packer/**` +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/**` +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/events/**` +- `prometeu-studio/src/main/java/p/studio/Container.java` +- `prometeu-studio/src/main/java/p/studio/events/**` +- `prometeu-app/src/main/java/p/studio/App.java` +- `prometeu-app/src/main/java/p/studio/AppContainer.java` diff --git a/docs/packer/pull-requests/PR-14-project-runtime-core-snapshot-model-and-lifecycle.md b/docs/packer/pull-requests/PR-14-project-runtime-core-snapshot-model-and-lifecycle.md new file mode 100644 index 00000000..b8708c9f --- /dev/null +++ b/docs/packer/pull-requests/PR-14-project-runtime-core-snapshot-model-and-lifecycle.md @@ -0,0 +1,68 @@ +# PR-14 Project Runtime Core, Snapshot Model, and Lifecycle + +Domain Owner: `docs/packer` + +## Briefing + +With cleanup and bootstrap in place, the packer can now introduce the actual project runtime. + +This PR adds the runtime boundary, snapshot state model, bootstrap/load behavior, and disposal lifecycle that later query and command services will use. + +## Objective + +Deliver the project-scoped runtime core and one coherent in-memory snapshot model for the active service wave. + +## Dependencies + +- [`./PR-12-cleanup-and-unused-surface-removal-before-runtime-service-wave.md`](./PR-12-cleanup-and-unused-surface-removal-before-runtime-service-wave.md) +- [`./PR-13-embedded-bootstrap-container-owned-event-bus-and-packer-composition-root.md`](./PR-13-embedded-bootstrap-container-owned-event-bus-and-packer-composition-root.md) +- [`../specs/2. Workspace, Registry, and Asset Identity Specification.md`](../specs/2.%20Workspace,%20Registry,%20and%20Asset%20Identity%20Specification.md) +- [`../specs/5. Diagnostics, Operations, and Studio Integration Specification.md`](../specs/5.%20Diagnostics,%20Operations,%20and%20Studio%20Integration%20Specification.md) + +## Scope + +- add the project runtime abstraction +- define snapshot content and generation ownership only for the active service wave +- define runtime bootstrap/load and disposal behavior +- isolate filesystem repositories behind the runtime boundary +- keep snapshot scope limited to the data needed by the active Studio-facing service path +- keep the runtime implementation inside `prometeu-packer-v1` while preserving the external contract in `prometeu-packer-api` + +## Non-Goals + +- no Studio adapter work yet +- no doctor/build/reconcile functionality +- no full query migration yet +- no write lane yet +- no speculative snapshot fields for capabilities that are not part of the active wave + +## Execution Method + +1. Introduce project runtime state/container types. +2. Load registry plus asset declarations into the runtime snapshot. +3. Define runtime generation, refresh, and disposal rules. +4. Keep the runtime state minimal and aligned with the currently used service surface. +5. Make later query/command work depend on this runtime boundary rather than direct filesystem scans. + +## Acceptance Criteria + +- one coherent runtime exists per active project +- runtime bootstrap/load is explicit and testable +- runtime disposal is explicit +- filesystem repositories are isolated behind the runtime boundary +- runtime state is limited to what the active Studio service wave actually consumes + +## Validation + +- runtime bootstrap tests +- snapshot generation tests +- lifecycle tests for bootstrap, refresh, and disposal + +## Affected Artifacts + +- `prometeu-packer/prometeu-packer-api/src/main/java/p/packer/**` +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/**` +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/**` +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/events/**` +- `prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/**` +- `prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/testing/**` diff --git a/docs/packer/pull-requests/PR-15-snapshot-backed-asset-query-services.md b/docs/packer/pull-requests/PR-15-snapshot-backed-asset-query-services.md new file mode 100644 index 00000000..ac50113a --- /dev/null +++ b/docs/packer/pull-requests/PR-15-snapshot-backed-asset-query-services.md @@ -0,0 +1,70 @@ +# PR-15 Snapshot-Backed Asset Query Services + +Domain Owner: `docs/packer` +Cross-Domain Impact: `docs/studio` + +## Briefing + +The first functional runtime-backed service wave should focus on queries. + +This PR moves the asset query surface used by Studio onto the runtime snapshot and defines coherent query behavior without expanding into doctor/build/reconcile. + +## Objective + +Deliver snapshot-backed query services for the currently used asset-management surface. + +## Dependencies + +- [`./PR-14-project-runtime-core-snapshot-model-and-lifecycle.md`](./PR-14-project-runtime-core-snapshot-model-and-lifecycle.md) +- [`../specs/2. Workspace, Registry, and Asset Identity Specification.md`](../specs/2.%20Workspace,%20Registry,%20and%20Asset%20Identity%20Specification.md) +- [`../specs/3. Asset Declaration and Virtual Asset Contract Specification.md`](../specs/3.%20Asset%20Declaration%20and%20Virtual%20Asset%20Contract%20Specification.md) +- cross-domain reference: [`../../studio/specs/4. Assets Workspace Specification.md`](../../studio/specs/4.%20Assets%20Workspace%20Specification.md) + +## Scope + +- migrate `init_workspace` +- migrate `list_assets` +- migrate `get_asset_details` +- keep the query path coherent through the runtime snapshot +- preserve the packer-owned summary/details contract used by Studio +- remove leftover query orchestration that only existed to feed inactive `doctor`, `build/pack`, or reconcile flows +- preserve the modular boundary where `prometeu-studio` consumes only `prometeu-packer-api` + +## Non-Goals + +- no command/write lane yet +- no mutation apply yet +- no doctor/build/reconcile + +## Execution Method + +1. Route the active query APIs through the runtime snapshot. +2. Preserve coherent results across repeated query use. +3. Remove repeated recomputation from the active query path. +4. Remove active query seams that only support deferred capabilities. +5. Keep Studio-facing response semantics stable. + +## Acceptance Criteria + +- active asset queries are served from the runtime snapshot +- normal query use no longer depends on full filesystem recomputation +- Studio-facing details/listing semantics remain stable +- no doctor/build/reconcile behavior is introduced by this PR +- unused query seams kept only for deferred capabilities are removed from the active path + +## Validation + +- regression tests for `init_workspace` +- regression tests for `list_assets` +- regression tests for `get_asset_details` +- Studio smoke validation for list/details loading + +## Affected Artifacts + +- `prometeu-packer/prometeu-packer-api/src/main/java/p/packer/**` +- `prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/**` +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/**` +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/**` +- `prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/**` +- `prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/testing/**` +- `prometeu-studio/**` query adapter coverage diff --git a/docs/packer/pull-requests/PR-16-write-lane-command-completion-and-used-write-services.md b/docs/packer/pull-requests/PR-16-write-lane-command-completion-and-used-write-services.md new file mode 100644 index 00000000..f882bc11 --- /dev/null +++ b/docs/packer/pull-requests/PR-16-write-lane-command-completion-and-used-write-services.md @@ -0,0 +1,71 @@ +# PR-16 Write Lane, Command Completion, and Used Write Services + +Domain Owner: `docs/packer` +Cross-Domain Impact: `docs/studio` + +## Briefing + +After queries are runtime-backed, the next step is the minimal command surface actually used by Studio. + +This PR introduces the project write lane, synchronous command completion semantics, and the write services currently needed by Studio, without reintroducing doctor, build, or reconcile work. + +## Objective + +Deliver the write lane plus only the write/command surface currently exercised by the Studio `Assets` workspace on top of the runtime model. + +## Dependencies + +- [`./PR-14-project-runtime-core-snapshot-model-and-lifecycle.md`](./PR-14-project-runtime-core-snapshot-model-and-lifecycle.md) +- [`./PR-15-snapshot-backed-asset-query-services.md`](./PR-15-snapshot-backed-asset-query-services.md) +- [`../decisions/Concurrency, Observability, and Studio Adapter Boundary Decision.md`](../decisions/Concurrency,%20Observability,%20and%20Studio%20Adapter%20Boundary%20Decision.md) +- [`../specs/5. Diagnostics, Operations, and Studio Integration Specification.md`](../specs/5.%20Diagnostics,%20Operations,%20and%20Studio%20Integration%20Specification.md) + +## Scope + +- implement the project-scoped write lane +- define durable visibility after successful commit +- define request/response command semantics with optional `Future`-based completion +- implement only the write surface currently used by Studio `Assets` +- preserve causal lifecycle events for command execution +- reintroduce command/write support in `prometeu-packer-v1` without collapsing the `prometeu-packer-api` boundary + +## Non-Goals + +- no doctor +- no build/pack +- no background reconcile observer + +## Execution Method + +1. Add the runtime-backed write lane. +2. Define synchronous command response plus optional `Future` completion semantics. +3. Reintroduce only the currently used write services onto the runtime. +4. Remove command surfaces that remain out of scope for the active Studio service wave. +5. Preserve asynchronous lifecycle events as observability, not as the primary command contract. + +## Acceptance Criteria + +- same-project commands are serialized by the packer +- committed state becomes visible only after successful durable commit +- synchronous command APIs may expose `Future` completion directly +- the write services currently used by Studio run on the runtime-backed path +- no doctor/build/reconcile behavior is introduced by this PR +- deferred command surfaces are not kept alive in the active implementation by placeholder adapters + +## Validation + +- write-lane concurrency tests +- commit failure/recovery tests +- command completion tests for response plus `Future` +- write regression tests for the active Studio write surface +- negative validation proving `doctor`, `build/pack`, and reconcile command paths are not part of the active wave + +## Affected Artifacts + +- `prometeu-packer/prometeu-packer-api/src/main/java/p/packer/**` +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/**` +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/**` +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/events/**` +- `prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/**` +- `prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/testing/**` +- `prometeu-studio/**` command integration coverage diff --git a/docs/packer/pull-requests/PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md b/docs/packer/pull-requests/PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md new file mode 100644 index 00000000..e62fda59 --- /dev/null +++ b/docs/packer/pull-requests/PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md @@ -0,0 +1,73 @@ +# PR-17 Studio Runtime Adapter and Assets Workspace Consumption + +Domain Owner: `docs/packer` +Cross-Domain Impact: `docs/studio` + +## Briefing + +Once the active query and command surface is runtime-backed, Studio should consume that path as a frontend without recreating packer semantics. + +This PR hardens the Studio adapters and the `Assets` workspace consumption path for the service-only wave while preserving the modular split between `prometeu-packer-api`, `prometeu-packer-v1`, `prometeu-studio`, and `prometeu-app`. + +## Objective + +Deliver the Studio-side adapter and `Assets` workspace integration for the active runtime-backed service surface. + +## Dependencies + +- [`./PR-13-embedded-bootstrap-container-owned-event-bus-and-packer-composition-root.md`](./PR-13-embedded-bootstrap-container-owned-event-bus-and-packer-composition-root.md) +- [`./PR-15-snapshot-backed-asset-query-services.md`](./PR-15-snapshot-backed-asset-query-services.md) +- [`./PR-16-write-lane-command-completion-and-used-write-services.md`](./PR-16-write-lane-command-completion-and-used-write-services.md) +- cross-domain reference: [`../../studio/specs/2. Studio UI Foundations Specification.md`](../../studio/specs/2.%20Studio%20UI%20Foundations%20Specification.md) +- cross-domain reference: [`../../studio/specs/4. Assets Workspace Specification.md`](../../studio/specs/4.%20Assets%20Workspace%20Specification.md) + +## Scope + +- adapt Studio to consume runtime-backed packer queries and commands +- preserve `request/response` as the primary integration model +- consume packer lifecycle events through the host bridge from `PackerEventSink` into the container-owned typed event bus path +- keep the `Assets` workspace aligned with the active service-only wave +- remove adapter branches that only exist for inactive `doctor`, `build/pack`, or reconcile usage +- keep `prometeu-studio` bound only to `prometeu-packer-api` +- let `prometeu-app` remain responsible for installing the concrete `Container` implementation, applying the `p.packer.Packer` entrypoint from `prometeu-packer-v1`, and bridging `PackerEventSink` into the host bus + +## Non-Goals + +- no doctor UI +- no pack/build UI +- no reconcile-state UI beyond what the current service wave actually exposes + +## Execution Method + +1. Update the Studio adapter layer to consume the runtime-backed service path. +2. Preserve translational mapping only. +3. Validate that `prometeu-studio` does not depend on `prometeu-packer-v1` classes directly. +4. Validate command submission plus event-driven lifecycle visibility through the host `PackerEventSink` bridge and shared bus path. +4. Remove adapter branches that only keep deferred capabilities artificially wired. +5. Keep the `Assets` workspace focused on the currently active service surface. + +## Acceptance Criteria + +- Studio remains a consumer of packer runtime semantics +- `Assets` workspace list/details/actions run through the active runtime-backed service path +- command submission plus event observation are coherent end to end +- no inactive doctor/build/reconcile surfaces are reintroduced +- Studio adapters no longer preserve dead branches for deferred capability families +- `prometeu-studio` depends only on `prometeu-packer-api` +- `prometeu-app` is the layer that binds the concrete `Container` implementation and the `p.packer.Packer` entrypoint from `prometeu-packer-v1` +- Studio consumes packer lifecycle visibility through a host-provided `PackerEventSink` bridge rather than by exposing host bus types inside packer contracts + +## Validation + +- Studio adapter tests +- `Assets` workspace smoke tests +- end-to-end tests for list/details/write flows used by Studio + +## Affected Artifacts + +- `prometeu-studio/src/main/java/p/studio/**` +- `prometeu-studio/src/test/java/p/studio/**` +- `prometeu-app/src/main/java/p/studio/**` +- `prometeu-packer/prometeu-packer-api/src/main/java/p/packer/**` integration-facing contracts +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/**` embedded runtime implementation surfaces +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/events/**` embedded runtime event surfaces diff --git a/docs/packer/pull-requests/PR-18-legacy-service-retirement-and-regression-hardening.md b/docs/packer/pull-requests/PR-18-legacy-service-retirement-and-regression-hardening.md new file mode 100644 index 00000000..15a324b3 --- /dev/null +++ b/docs/packer/pull-requests/PR-18-legacy-service-retirement-and-regression-hardening.md @@ -0,0 +1,66 @@ +# PR-18 Legacy Service Retirement and Regression Hardening + +Domain Owner: `docs/packer` + +## Briefing + +After the active service-only wave is fully running through the runtime path, the repository should not keep duplicated legacy orchestration around as a competing semantic track. + +This PR retires the superseded legacy paths and hardens regression coverage around the smaller active service surface. +It also closes the cleanup promise from `PR-12` by ensuring no unused packer capability families survive just because they existed before the runtime service wave. + +## Objective + +Remove superseded legacy service paths and strengthen regression protection for the runtime-backed service wave. + +## Dependencies + +- [`./PR-15-snapshot-backed-asset-query-services.md`](./PR-15-snapshot-backed-asset-query-services.md) +- [`./PR-16-write-lane-command-completion-and-used-write-services.md`](./PR-16-write-lane-command-completion-and-used-write-services.md) +- [`./PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md`](./PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md) + +## Scope + +- retire duplicated filesystem-per-call paths superseded by the active runtime-backed service wave +- remove temporary shims that were tolerated only during the migration window +- harden regression coverage around the remaining active service surface +- remove leftover inactive `doctor`, `build/pack`, and reconcile code that no longer belongs to the service-only wave +- preserve the `prometeu-packer-api` surface as the stable consumer contract while retiring legacy implementation paths in `prometeu-packer-v1` + +## Non-Goals + +- no doctor reintroduction +- no build/pack reintroduction +- no reconcile observer work +- no new architecture decisions + +## Execution Method + +1. Remove superseded legacy paths. +2. Remove temporary migration shims once the runtime-backed path is complete. +3. Simplify the active service composition around the runtime boundary. +4. Strengthen regression coverage around the remaining service wave. +5. Verify no split-brain semantics remain between active and legacy paths. + +## Acceptance Criteria + +- superseded legacy paths are removed +- the active runtime-backed service wave is the only semantic path for the currently used functionality +- regression coverage protects the reduced active surface +- no inactive capability family survives in code solely as speculative future support + +## Validation + +- full active-service regression suite +- Studio embedding regression suite +- targeted tests proving no disagreement remains between active and legacy paths + +## Affected Artifacts + +- `prometeu-packer/prometeu-packer-api/src/main/java/p/packer/**` +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/**` +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/**` +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/events/**` +- `prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/**` +- `prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/testing/**` +- integration fixtures diff --git a/docs/packer/pull-requests/README.md b/docs/packer/pull-requests/README.md index 6c3d0b79..3ecf108f 100644 --- a/docs/packer/pull-requests/README.md +++ b/docs/packer/pull-requests/README.md @@ -70,7 +70,22 @@ The current production track for the standalone `prometeu-packer` project is: 8. [`PR-08-assets-pa-and-companion-artifact-emission.md`](./PR-08-assets-pa-and-companion-artifact-emission.md) 9. [`PR-09-event-lane-progress-and-studio-operational-integration.md`](./PR-09-event-lane-progress-and-studio-operational-integration.md) 10. [`PR-10-versioning-migration-trust-and-production-gates.md`](./PR-10-versioning-migration-trust-and-production-gates.md) +11. [`PR-11-packer-runtime-restructure-snapshot-authority-and-durable-commit.md`](./PR-11-packer-runtime-restructure-snapshot-authority-and-durable-commit.md) +12. [`PR-12-cleanup-and-unused-surface-removal-before-runtime-service-wave.md`](./PR-12-cleanup-and-unused-surface-removal-before-runtime-service-wave.md) +13. [`PR-13-embedded-bootstrap-container-owned-event-bus-and-packer-composition-root.md`](./PR-13-embedded-bootstrap-container-owned-event-bus-and-packer-composition-root.md) +14. [`PR-14-project-runtime-core-snapshot-model-and-lifecycle.md`](./PR-14-project-runtime-core-snapshot-model-and-lifecycle.md) +15. [`PR-15-snapshot-backed-asset-query-services.md`](./PR-15-snapshot-backed-asset-query-services.md) +16. [`PR-16-write-lane-command-completion-and-used-write-services.md`](./PR-16-write-lane-command-completion-and-used-write-services.md) +17. [`PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md`](./PR-17-studio-runtime-adapter-and-assets-workspace-consumption.md) +18. [`PR-18-legacy-service-retirement-and-regression-hardening.md`](./PR-18-legacy-service-retirement-and-regression-hardening.md) + +Current wave discipline from `PR-11` onward: + +- cleanup and active-surface reduction happen before runtime implementation; +- the wave is service-first and Studio-driven; +- `doctor`, `build/pack`, and background reconcile are explicitly deferred; +- code that is not used by the active service wave should be removed instead of preserved speculatively. Recommended dependency chain: -`PR-01 -> PR-02 -> PR-03 -> PR-04 -> PR-05 -> PR-06 -> PR-07 -> PR-08 -> PR-09 -> PR-10` +`PR-01 -> PR-02 -> PR-03 -> PR-04 -> PR-05 -> PR-06 -> PR-07 -> PR-08 -> PR-09 -> PR-10 -> PR-11 -> PR-12 -> PR-13 -> PR-14 -> PR-15 -> PR-16 -> PR-17 -> PR-18` diff --git a/docs/packer/specs/2. Workspace, Registry, and Asset Identity Specification.md b/docs/packer/specs/2. Workspace, Registry, and Asset Identity Specification.md index 6d888959..405cd9f5 100644 --- a/docs/packer/specs/2. Workspace, Registry, and Asset Identity Specification.md +++ b/docs/packer/specs/2. Workspace, Registry, and Asset Identity Specification.md @@ -20,10 +20,11 @@ This specification consolidates the initial packer agenda and decision wave into 2. One asset root contains exactly one anchor `asset.json`. 3. `assets/.prometeu/index.json` is the authoritative registry of registered assets. 4. `asset.json` is the authoritative asset-local declaration. -5. An asset root absent from the registry is `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. +5. `asset.json` must carry the stable `asset_uuid` identity anchor for that asset root. +6. An asset root absent from the registry is `unregistered`. +7. `unregistered` assets are always `excluded` from build participation. +8. Registered assets may be `included` or `excluded` from build participation without losing identity. +9. The baseline build set includes registered assets whose registry entry is marked as build-included. ## Identity Model @@ -33,6 +34,12 @@ Each registered asset has: - `asset_uuid`: stable long-lived identity for migration/tooling scenarios - `included_in_build`: build participation flag persisted in the registry +Identity authority is intentionally split: + +- `asset_uuid` is anchored locally in `asset.json`; +- `asset_id` is allocated and persisted by the registry; +- registry-managed location and build participation remain catalog concerns in `index.json`. + The following are not primary identity: - `asset_name` @@ -41,6 +48,18 @@ The following are not primary identity: `asset_name` may still be used by authoring and runtime-facing APIs as a logical reference label. +## Local Identity Anchor + +`asset_uuid` is the stable asset-local identity anchor. + +Rules: + +- `asset_uuid` must be present in `asset.json`; +- `asset_uuid` is stable across relocate and rename flows; +- `asset_uuid` is not allocated from path shape or `asset_name`; +- `asset_uuid` allows the packer to reconcile manual workspace changes with the registry/catalog model; +- `asset_uuid` does not replace registry authority over `asset_id`, build participation, or managed root tracking. + ## Relocation and Rename Moving or renaming an asset root does not change identity. @@ -62,7 +81,8 @@ Rules: - it is excluded from the build automatically; - it is diagnosable; -- it becomes registered only through explicit flow. +- it becomes registered only through explicit flow; +- its local `asset_uuid` still matters for structural validation and future reconcile behavior. ## Build Participation @@ -94,7 +114,21 @@ Examples: - duplicate or ambiguous anchors under registered expectations; - manual copy that creates identity collision; -- registered root missing anchor. +- registered root missing anchor; +- duplicate `asset_uuid` across different asset roots; +- registry/catalog location that no longer matches the asset root carrying the expected `asset_uuid`; +- `asset.json` missing or malformed in a root expected to preserve identity. + +## Reconcile Expectations + +The packer may need to reconcile registry/catalog state against the authoring workspace. + +Rules: + +- manual move or rename of an asset root must not imply identity loss by itself; +- reconcile should prefer `asset_uuid` when matching a durable asset identity across changed paths; +- path drift must not silently rebind one registered asset to another distinct `asset_uuid`; +- unresolved identity drift remains diagnosable until explicit repair or successful reconcile. ## Non-Goals diff --git a/docs/packer/specs/3. Asset Declaration and Virtual Asset Contract Specification.md b/docs/packer/specs/3. Asset Declaration and Virtual Asset Contract Specification.md index 0ae59846..89368b27 100644 --- a/docs/packer/specs/3. Asset Declaration and Virtual Asset Contract Specification.md +++ b/docs/packer/specs/3. Asset Declaration and Virtual Asset Contract Specification.md @@ -13,6 +13,7 @@ This specification consolidates the initial packer agenda and decision wave into The common `asset.json` contract requires these top-level fields: - `schema_version` +- `asset_uuid` - `name` - `type` - `inputs` @@ -23,6 +24,19 @@ The common contract may also include: - `build` +## Meaning of `asset_uuid` + +`asset_uuid` is the stable asset-local identity anchor. + +Rules: + +- it is required; +- it must be stable across relocate and rename flows; +- it is not the project-local runtime artifact identity; +- it is not a substitute for registry-owned `asset_id`; +- it exists so the asset root can preserve identity even when path-based assumptions drift; +- it must remain compatible with packer reconcile behavior and migration flows. + ## Meaning of `name` `name` is the logical asset reference label. @@ -90,6 +104,17 @@ Rules: - if a parameter affects the runtime-facing output contract, it belongs in `output.metadata`; - `build` must not hide runtime-relevant semantics. +## Operational State Exclusion + +`asset.json` is a declaration artifact, not a catalog cache. + +Rules: + +- transient UI state must not be stored in `asset.json`; +- registry-managed fields such as `asset_id` and `included_in_build` must not be duplicated into `asset.json`; +- packer cache or snapshot bookkeeping must not be materialized into `asset.json` as normal operational state; +- `asset.json` should remain focused on identity anchoring plus declared authoring/packing behavior. + ## Preload Each registered asset must declare preload intent explicitly. diff --git a/docs/packer/specs/5. Diagnostics, Operations, and Studio Integration Specification.md b/docs/packer/specs/5. Diagnostics, Operations, and Studio Integration Specification.md index 298ce979..9a52c3f4 100644 --- a/docs/packer/specs/5. Diagnostics, Operations, and Studio Integration Specification.md +++ b/docs/packer/specs/5. Diagnostics, Operations, and Studio Integration Specification.md @@ -44,6 +44,26 @@ Rules: The normative operational surface is service-based. +The packer is a filesystem-first operational runtime. + +Rules: + +- the active project may maintain a coherent in-memory operational snapshot; +- normal read requests should be served from that coherent snapshot when the runtime is active; +- the runtime snapshot is an operational projection of packer-owned workspace artifacts, not a replacement authoring store; +- the durable authoring workspace remains the persisted source of truth after successful commit. + +### Embedded Bootstrap Rule + +When the packer is embedded inside another host such as Studio, the host must bootstrap the packer integration explicitly. + +Rules: + +- the embedded packer runtime must receive a typed event bus reference from the host when shared host visibility is part of the integration contract; +- that reference is the baseline path for packer publish/subscribe integration in the embedded host; +- the embedded packer runtime must not quietly create a disconnected parallel event system when host integration expects shared operational visibility; +- in Studio, this shared typed event bus reference is owned by the Studio `Container`. + Baseline core services: - `init_workspace` @@ -66,6 +86,26 @@ Operations must distinguish: This distinction is part of the service semantics and must be visible to the UI. +## Runtime State and Visibility + +The packer runtime must make operational freshness explicit. + +At minimum, the model should support states equivalent to: + +- `healthy` +- `stale` +- `diverged` +- `committing` +- `reconciling` +- `failed` + +Rules: + +- successful durable commit defines the visibility boundary for committed write state; +- reads must not present torn in-progress writes as committed truth; +- background divergence detection may move a project or asset view into stale/diverged/reconciling state; +- Studio may render these states, but must not invent their semantics. + ## Concurrency Model The packer concurrency model is conservative and project-scoped. @@ -84,6 +124,13 @@ Rules: - build/write on the same project is serialized unless a future spec introduces an explicit transactional coordination model; - background observation may continue while a serialized write lane is active, but it must not publish misleading post-state before commit visibility. +### Write Ownership + +- writes on one project execute through a packer-owned write lane; +- caller timing must not define final write interleaving semantics; +- preview generation may occur before the final commit section when safe; +- apply/commit remains the packer-owned visibility boundary. + ## Preview/Apply Model Sensitive mutations use staged intent. @@ -102,6 +149,7 @@ At minimum, the model should support fields equivalent to: - `status` - `summary` +- `runtime_state` - `affected_assets` - `diagnostics` - `proposed_actions` @@ -118,6 +166,7 @@ Responsibilities: - report diagnostics/build/cache activity to the UI; - support live refresh and progress reporting; - avoid blocking the main UI thread. +- surface operational freshness and reconcile transitions when they affect Studio-visible state. ### Initial Event Set @@ -135,6 +184,8 @@ The initial structured event set includes: - `action_failed` - `progress_updated` +Additional runtime/reconcile state events may exist as the operational runtime evolves, but adapters must preserve their causal meaning instead of collapsing them into generic refresh noise. + ### Mutation Serialization Mutating asset workflows are serialized semantically. @@ -148,6 +199,7 @@ Rules: - preview generation may run outside the final commit section, but apply/commit remains serialized; - build and sensitive mutation apply must not interleave on the same project in a way that obscures final state; - cancellation or failure must leave the observable project state in a coherent post-operation condition. +- divergence detection must not silently rewrite user-authored workspace content as if it were a normal mutation apply. ### Event Envelope @@ -169,6 +221,7 @@ Rules: - `operation_id` is stable for the full lifecycle of one logical operation; - `sequence` is monotonic within one operation; - adapters may remap event shapes for UI consumption, but must not invent causal relationships not present in packer events. +- embedded hosts should preserve the same typed event bus reference across packer bootstrap and adapter wiring so subscription and publication stay causally coherent. ### Event Ordering and Coalescing diff --git a/docs/packer/specs/6. Versioning, Migration, and Trust Model Specification.md b/docs/packer/specs/6. Versioning, Migration, and Trust Model Specification.md index e784b958..ace73af3 100644 --- a/docs/packer/specs/6. Versioning, Migration, and Trust Model Specification.md +++ b/docs/packer/specs/6. Versioning, Migration, and Trust Model Specification.md @@ -42,6 +42,7 @@ Rules: - migration may be automatic within the supported window; - unsupported versions fail clearly and early; - migration failures must be diagnosable in Studio and CI. +- migration may need to preserve or repair identity-bearing fields such as `asset_uuid` without fabricating a different asset identity silently. ## Runtime Compatibility Boundary @@ -73,6 +74,7 @@ Untrusted until validated: - hand-edited or legacy declarations; - imported external project content; - legacy packer artifacts and control data. +- runtime snapshot observations that have not yet been reconciled against changed workspace files. Trusted only after: @@ -80,6 +82,7 @@ Trusted only after: - structural validation; - semantic validation; - version compatibility check. +- reconcile or refresh when divergence between snapshot and filesystem has been detected. ## Plugin and Script Execution @@ -102,6 +105,7 @@ At minimum, diagnostics should make clear: - the version found; - the supported range or expectation; - whether migration was attempted; +- whether identity-bearing fields or registry/catalog alignment were implicated; - whether manual action is required; - whether the failure blocks build or Studio workflow. diff --git a/docs/studio/pull-requests/PR-08-assets-workspace-panel-package-boundaries-and-local-subscriptions.md b/docs/studio/pull-requests/PR-08-assets-workspace-panel-package-boundaries-and-local-subscriptions.md new file mode 100644 index 00000000..dd1c268f --- /dev/null +++ b/docs/studio/pull-requests/PR-08-assets-workspace-panel-package-boundaries-and-local-subscriptions.md @@ -0,0 +1,117 @@ +# PR-08 Assets Workspace Panel Package Boundaries and Local Subscriptions + +Domain owner: `docs/studio` + +## Briefing + +The current `Assets` workspace still keeps too much control logic concentrated in top-level workspace-area classes. + +That leaves package boundaries flatter than they should be and still weakens the intended Studio model: + +- every panel should own its own workspace-bus subscriptions; +- `AssetWorkspace` should stay as composition root and orchestration layer; +- the asset list should live in its own package area; +- the full right-hand details side should be split into package-owned panels with direct lifecycle-managed subscriptions. + +This refactor is a structural follow-up to the `PR-07` family. + +It does not redefine the event-driven direction; it completes it by enforcing package topology and subscription ownership at the panel level. + +## Objective + +Reorganize the `Assets` workspace into explicit package areas so that every list or details panel consumes the workspace event bus directly and subscribes only to the state it needs. + +After this PR: + +- `AssetWorkspace` composes package-scoped controls instead of hosting panel logic directly; +- all asset-list controls live under an `asset list` package area; +- the right-hand details side is organized under `details/...` package areas; +- `summary`, `actions`, `contract`, `preview`, and `diagnostics` each manage their own subscriptions; +- the package layout itself teaches the correct Studio workspace architecture. + +## Dependencies + +- [`./PR-07a-assets-event-topology-and-lifecycle-foundation.md`](./PR-07a-assets-event-topology-and-lifecycle-foundation.md) +- [`./PR-07b-asset-navigator-and-row-subscriptions.md`](./PR-07b-asset-navigator-and-row-subscriptions.md) +- [`./PR-07c-asset-details-and-form-lifecycle.md`](./PR-07c-asset-details-and-form-lifecycle.md) +- [`./PR-07e-assets-refactor-cleanup-and-regression-coverage.md`](./PR-07e-assets-refactor-cleanup-and-regression-coverage.md) +- [`../specs/4. Assets Workspace Specification.md`](../specs/4.%20Assets%20Workspace%20Specification.md) + +## Scope + +- create an `asset list` package area under the `Assets` workspace implementation +- move the asset-list host and asset-list item control into that package area +- require both asset-list host and asset-list item to receive `StudioWorkspaceEventBus` +- require both asset-list host and asset-list item to own their own lifecycle-managed subscriptions +- create a `details` package area for the full right-hand side of the workspace +- split details internals into package-owned subareas such as: + - `details/summary` + - `details/actions` + - `details/contract` + - `details/preview` + - `details/diagnostics` +- require each details panel to subscribe directly to the event stream it consumes +- reduce coordinator-style pass-through logic where a child panel can consume the workspace bus directly +- keep shared details support code only where it removes real duplication without re-centralizing subscriptions + +## Non-Goals + +- no new mutation semantics +- no new global event-bus abstraction +- no visual redesign of the workspace +- no cross-workspace extraction unless a primitive is already justified by this refactor +- no return to top-level refresh orchestration as the normal update model + +## Execution Method + +1. Define the target package topology. + The package tree should reflect workspace areas, not arbitrary implementation convenience. + +2. Move asset-list code into a dedicated package area. + The list host and list item should be colocated and should consume the workspace bus directly. + +3. Normalize asset-list subscriptions. + The asset-list host should subscribe to list-level projection state. + The asset-list item should subscribe to item-local concerns such as selection and asset patch events. + +4. Move the full right-hand details side into a dedicated `details` package area. + The top-level details host should stay thin and should mount panel controls by workspace area. + +5. Split details panels by concern. + `summary`, `actions`, `contract`, `preview`, and `diagnostics` should each live in package-owned subareas and subscribe for themselves. + +6. Remove parent-owned update routing where it is only forwarding state to children. + If a child panel can subscribe to the workspace bus safely, it should do so directly. + +7. Re-check constructor contracts. + Every event-consuming panel should receive the `StudioWorkspaceEventBus` explicitly, plus only the interaction ports it truly needs. + +8. Clean naming and file layout. + Class names, package names, and placement should make the `Assets` workspace structure obvious to a new maintainer. + +## Acceptance Criteria + +- there is a dedicated package area for the asset list inside the `Assets` workspace +- asset-list host and asset-list item both receive `StudioWorkspaceEventBus` +- asset-list host and asset-list item both subscribe directly to the events they need +- there is a dedicated `details` package area for the right-hand workspace side +- `summary`, `actions`, `contract`, `preview`, and `diagnostics` each live in their own package-owned area +- each details panel owns its own lifecycle-managed subscriptions +- `AssetWorkspace` no longer acts as the effective subscriber for panel-internal state +- package structure and constructor boundaries make the lifecycle model explicit + +## Validation + +- unit tests for lifecycle subscribe/unsubscribe on moved controls +- unit tests or focused integration tests proving list item and details panels react from their own subscriptions +- regression validation that asset selection and local patch flows still update without workspace-wide refresh +- package-level review that no event-consuming panel is left without direct bus access + +## Affected Artifacts + +- `prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java` +- `prometeu-studio/src/main/java/p/studio/workspaces/assets/...` moved into list/details package areas +- asset-list controls under a dedicated list package +- details controls under `details/...` packages +- tests for workspace lifecycle and subscription ownership +- `docs/studio/specs/4. Assets Workspace Specification.md` if package/lifecycle wording needs tightening after the refactor diff --git a/docs/studio/pull-requests/PR-09-asset-move-action-and-relocate-wizard.md b/docs/studio/pull-requests/PR-09-asset-move-action-and-relocate-wizard.md new file mode 100644 index 00000000..21c32661 --- /dev/null +++ b/docs/studio/pull-requests/PR-09-asset-move-action-and-relocate-wizard.md @@ -0,0 +1,164 @@ +# PR-09 Asset Move Action and Relocate Wizard + +Domain owner: `docs/studio` + +## Briefing + +Adicionar a action `Move` na área de actions do asset selecionado e conectá-la a um wizard de relocação explícita. + +O usuário deve escolher o destino do asset e revisar um resumo antes da execução. +Depois da confirmação, o Studio não move diretórios localmente. +Ele apenas envia um comando de relocação para o packer, que passa a ser o owner completo da mudança: + +- atualiza o estado interno necessário; +- move o diretório do asset dentro da árvore `assets` do projeto; +- emite eventos operacionais até a conclusão. + +Este plano também fecha uma regra operacional que precisa existir nas duas pontas: + +- o diretório destino final do asset não pode já ser um root de asset; +- portanto, o root destino não pode conter `asset.json`. + +Após a confirmação do resumo, o modal deve entrar em estado de espera com spinner, escutando o evento operacional do packer. +Quando a operação terminar: + +- sucesso: o Studio dispara refresh, fecha o modal e reposiciona a seleção; +- falha: o modal sai do estado de espera e mostra a falha sem fechar silenciosamente. + +## Objective + +Entregar um fluxo de `Move` que seja explícito, previsível e compatível com o modelo em que o packer executa a mutação real e o Studio apenas comanda e observa. + +Após este PR: + +- a seção `Actions` do selected asset expõe `Move`; +- clicar em `Move` abre um wizard dedicado; +- o wizard coleta o parent de destino e o nome final do diretório; +- o wizard mostra um passo final de resumo antes do comando final; +- o Studio não aceita um destino cujo root já contenha `asset.json`; +- o packer também rejeita esse destino como regra de segurança e conformance; +- a confirmação final envia um comando de relocation para o packer via API; +- o modal entra em espera com spinner até receber o evento terminal da operação; +- o refresh estrutural só ocorre depois do evento de conclusão do packer. + +## Dependencies + +- [`./PR-05e-assets-staged-mutations-preview-and-apply.md`](./PR-05e-assets-staged-mutations-preview-and-apply.md) +- [`./PR-07c-asset-details-and-form-lifecycle.md`](./PR-07c-asset-details-and-form-lifecycle.md) +- [`./PR-07d-asset-mutation-and-structural-sync-orchestration.md`](./PR-07d-asset-mutation-and-structural-sync-orchestration.md) +- [`../specs/4. Assets Workspace Specification.md`](../specs/4.%20Assets%20Workspace%20Specification.md) +- cross-domain reference: [`../../packer/pull-requests/PR-05-sensitive-mutations-preview-apply-and-studio-write-adapter.md`](../../packer/pull-requests/PR-05-sensitive-mutations-preview-apply-and-studio-write-adapter.md) +- cross-domain reference: [`../../packer/pull-requests/PR-09-event-lane-progress-and-studio-operational-integration.md`](../../packer/pull-requests/PR-09-event-lane-progress-and-studio-operational-integration.md) + +## Scope + +- adicionar a action `Move` na seção `Actions` do details +- introduzir um `Relocate Wizard` efetivamente utilizável pelo selected asset atual +- coletar destino por: + - parent directory + - destination name + - target root derivado +- mostrar passo final de resumo antes da confirmação +- enviar um comando `RELOCATE_ASSET` com `targetRoot` explícito para o packer +- abrir estado modal de espera com spinner após confirmação +- correlacionar a operação via `operationId` +- escutar o evento operacional do packer até `ACTION_APPLIED` ou `ACTION_FAILED` +- publicar structural sync explícito apenas após conclusão bem-sucedida +- validar no Studio que `targetRoot/asset.json` não exista +- validar no packer que `targetRoot/asset.json` não exista, mesmo se o Studio falhar em bloquear antes + +## Non-Goals + +- não redesenhar o mutation preview panel inteiro +- não introduzir rename inline fora do wizard +- não adicionar batch move +- não redefinir semântica de identidade do asset +- não permitir fallback para target automático quando o usuário iniciou `Move` +- não mover diretórios diretamente pelo Studio fora do comando ao packer + +## Execution Method + +1. Expor `Move` na seção `Actions`. + O botão deve existir apenas quando houver asset selecionado e deve abrir o wizard a partir do `AssetReference` atual. + +2. Implementar o wizard de relocação como fluxo explícito de destino. + O wizard deve reutilizar a linguagem já existente de `relocateWizard` e coletar: + - root atual + - diretório parent de destino + - nome final do diretório + - target root calculado + +3. Adicionar validação local de destino. + O wizard deve bloquear avanço/confirmação quando: + - o target root for igual ao root atual; + - o target root sair da área válida esperada; + - o target root já contiver `asset.json`. + +4. Adicionar passo final de resumo. + Antes do comando final, o usuário deve ver um resumo do: + - asset atual + - root atual + - parent de destino + - nome final + - target root resultante + +5. Integrar o wizard ao fluxo de comando assíncrono do packer. + A saída do wizard deve virar `PackerMutationRequest(RELOCATE_ASSET, ..., targetRoot)`, sem usar target automático. + Depois do `OK`, o Studio inicia a operação, captura o `operationId` retornado e coloca o modal em estado de espera. + +6. Esperar conclusão por evento, não por refresh cego. + O modal deve escutar `StudioPackerOperationEvent` correlacionado por `operationId`. + Regras: + - `ACTION_APPLIED`: disparar refresh estrutural, fechar modal e reselecionar o asset relocado; + - `ACTION_FAILED`: sair do spinner, manter modal aberto e mostrar a falha; + - eventos intermediários como `PROGRESS_UPDATED` podem atualizar a mensagem/estado visual, mas não fecham o modal. + +7. Endurecer a regra no packer. + O preview/apply de relocation deve recusar destino cujo root já contenha `asset.json`, produzindo blocker claro e estável. + +8. Orquestrar o pós-conclusão como mudança estrutural. + Relocation bem-sucedida deve acionar sync estrutural explícito e reposicionar a seleção para o asset movido no novo root. + +## Acceptance Criteria + +- `Move` aparece na seção `Actions` do asset selecionado +- clicar em `Move` abre wizard dedicado em vez de mutação imediata +- o wizard exige destino explícito escolhido pelo usuário +- existe passo final de resumo antes da conclusão +- `targetRoot/asset.json` bloqueia o fluxo já no Studio +- o packer também bloqueia `targetRoot/asset.json` como regra de segurança +- o `OK` final envia o comando ao packer e coloca o modal em espera com spinner +- o modal só fecha depois de `ACTION_APPLIED` para a mesma operação +- falha operacional não fecha o modal silenciosamente +- sucesso operacional executa structural sync explícito +- a seleção final aponta para o asset já movido, não para o root antigo + +## Validation + +- unit tests para validação de target root no wizard +- unit tests para derivação de `targetRoot` a partir de parent + destination name +- unit tests para correlação `operationId -> modal state` +- unit tests para sucesso/falha dirigidos por `StudioPackerOperationEvent` +- unit tests para publication/orchestration de relocation como structural sync +- packer tests para blocker quando `targetRoot/asset.json` já existe +- smoke test de UI para: + - abrir `Move` + - editar destino + - ver resumo final + - bloquear destino inválido + - confirmar operação + - ver spinner de espera + - fechar no evento de sucesso + - manter aberto no evento de falha + +## Affected Artifacts + +- `docs/studio/specs/4. Assets Workspace Specification.md` se a regra de destino inválido precisar ser tornada mais explícita +- `prometeu-studio/src/main/java/p/studio/workspaces/assets/details/actions/...` +- `prometeu-studio/src/main/java/p/studio/workspaces/assets/wizards/...` +- `prometeu-studio/src/main/java/p/studio/workspaces/assets/...` mutation orchestration wiring +- `prometeu-studio/src/main/java/p/studio/events/...` +- `prometeu-studio/src/main/resources/i18n/messages.properties` +- `prometeu-packer/src/main/java/p/packer/mutations/...` +- testes de Studio para wizard/flow +- testes de packer para relocation target validation diff --git a/docs/studio/pull-requests/README.md b/docs/studio/pull-requests/README.md index 712c7170..0c511db1 100644 --- a/docs/studio/pull-requests/README.md +++ b/docs/studio/pull-requests/README.md @@ -48,12 +48,16 @@ The current Studio execution queue is: 8. [`PR-07c-asset-details-and-form-lifecycle.md`](./PR-07c-asset-details-and-form-lifecycle.md) 9. [`PR-07d-asset-mutation-and-structural-sync-orchestration.md`](./PR-07d-asset-mutation-and-structural-sync-orchestration.md) 10. [`PR-07e-assets-refactor-cleanup-and-regression-coverage.md`](./PR-07e-assets-refactor-cleanup-and-regression-coverage.md) -11. [`PR-06-project-scoped-studio-state-and-activity-persistence.md`](./PR-06-project-scoped-studio-state-and-activity-persistence.md) +11. [`PR-08-assets-workspace-panel-package-boundaries-and-local-subscriptions.md`](./PR-08-assets-workspace-panel-package-boundaries-and-local-subscriptions.md) +12. [`PR-06-project-scoped-studio-state-and-activity-persistence.md`](./PR-06-project-scoped-studio-state-and-activity-persistence.md) The `PR-07` family is a corrective refactor pass for the current `Assets` implementation. It exists to replace the refresh-heavy direction with lifecycle-managed, event-driven ownership. It also establishes the intended Studio-wide workspace framework, with `Assets` as the first consumer and proof point. +`PR-08` is the package-topology and subscription-ownership follow-up for that same direction. +It enforces list/details package boundaries and requires every panel to consume the workspace bus directly. + Recommended execution order: -`PR-05a -> PR-05b -> PR-05c -> PR-05d -> PR-05e -> PR-07a -> PR-07b -> PR-07c -> PR-07d -> PR-07e -> PR-06` +`PR-05a -> PR-05b -> PR-05c -> PR-05d -> PR-05e -> PR-07a -> PR-07b -> PR-07c -> PR-07d -> PR-07e -> PR-08 -> PR-06` diff --git a/docs/studio/specs/2. Studio UI Foundations Specification.md b/docs/studio/specs/2. Studio UI Foundations Specification.md index ec19cad2..5d219501 100644 --- a/docs/studio/specs/2. Studio UI Foundations Specification.md +++ b/docs/studio/specs/2. Studio UI Foundations Specification.md @@ -73,6 +73,14 @@ Propagation rules: - propagation is workspace to global; - global-to-workspace rebroadcast is not baseline behavior. +The global Studio bus is backed by a typed event bus reference owned by the Studio application container. + +Container rules: + +- the Studio `Container` owns the global typed event bus reference used by `StudioEventBus`; +- host-integrated services that need shared Studio operational visibility should receive that container-owned typed event bus reference during bootstrap instead of allocating disconnected local buses; +- the `Container` must be initialized during Studio boot before packer-backed integration services, workspaces, or adapters begin use. + This topology exists so callers do not need to duplicate publication decisions manually. ## Event Categories diff --git a/docs/studio/specs/4. Assets Workspace Specification.md b/docs/studio/specs/4. Assets Workspace Specification.md index c5d3f656..48ec204c 100644 --- a/docs/studio/specs/4. Assets Workspace Specification.md +++ b/docs/studio/specs/4. Assets Workspace Specification.md @@ -49,7 +49,10 @@ The workspace must assume: - unregistered assets remain visible but excluded from builds; - assets may aggregate many internal inputs; - runtime-facing output contract data exists for each asset; -- diagnostics, activity, progress, and staged mutation responses are available from Studio-facing services. +- diagnostics, activity, progress, and staged mutation responses are available from Studio-facing services; +- packer-facing operational freshness may surface states such as `healthy`, `stale`, `diverged`, `reconciling`, or `failed`. + +Packer-backed operational events consumed by this workspace are expected to enter Studio through the container-owned typed event bus path rather than through workspace-local ad hoc bus creation. ## Workspace Model @@ -66,7 +69,8 @@ The workspace must help the user understand: - where the asset root lives; - which internal inputs belong to that asset; - what the asset declares toward the runtime-facing contract; -- which operations are safe, staged, blocked, or destructive. +- which operations are safe, staged, blocked, or destructive; +- whether the current view reflects healthy, stale, diverged, or reconciling packer state. The `Assets` workspace is also the first concrete consumer of the Studio event-driven workspace framework. @@ -170,6 +174,7 @@ Rules: - The navigator must define explicit `no assets` state. - The navigator must define explicit `no results` state. - The navigator must define explicit `workspace error` state. +- The workspace must define explicit operational freshness states when packer runtime divergence is surfaced. - Unregistered assets must appear in the same navigator flow as registered assets. - Unregistered styling must communicate `present but not yet registered`, not `broken`. @@ -241,6 +246,7 @@ Rules: - Actions that target the selected asset must appear beside `Summary`, not in the navigator area. - Safe and routine actions must be visually separated from sensitive mutations. - Hidden or automatic repair behavior is not allowed in the selected-asset view. +- If packer surfaces stale/diverged/reconciling state, the selected-asset view may expose explicit refresh or reconcile-oriented actions, but it must not invent repair semantics locally. ## Action Rules @@ -334,6 +340,7 @@ Navigator-level `Doctor` and `Pack`, plus asset-level `Include In Build` and suc - `preview_ready` should surface in global `Activity`. - `action_applied` should surface in global `Activity`. - `action_failed` should surface in global `Activity`. +- reconcile or divergence state transitions should remain visible when they affect current workspace truth. - The preview itself remains a workspace-local review surface. ## Non-Goals diff --git a/prometeu-app/build.gradle.kts b/prometeu-app/build.gradle.kts new file mode 100644 index 00000000..59d3d29b --- /dev/null +++ b/prometeu-app/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id("gradle.java-application-conventions") + alias(libs.plugins.javafx) +} + +dependencies { + implementation(project(":prometeu-infra")) + implementation(project(":prometeu-packer:prometeu-packer-api")) + implementation(project(":prometeu-packer:prometeu-packer-v1")) + implementation(project(":prometeu-studio")) + implementation(libs.javafx.controls) + implementation(libs.javafx.fxml) +} + +application { + mainClass = "p.studio.App" +} + +javafx { + version = libs.versions.javafx.get() + modules("javafx.controls", "javafx.fxml") +} diff --git a/prometeu-studio/src/main/java/p/studio/App.java b/prometeu-app/src/main/java/p/studio/App.java similarity index 73% rename from prometeu-studio/src/main/java/p/studio/App.java rename to prometeu-app/src/main/java/p/studio/App.java index 95cacfd1..f4233600 100644 --- a/prometeu-studio/src/main/java/p/studio/App.java +++ b/prometeu-app/src/main/java/p/studio/App.java @@ -9,7 +9,7 @@ public class App extends Application { @Override public void init() throws Exception { super.init(); - Container.init(); + Container.install(new AppContainer()); } @Override @@ -17,6 +17,12 @@ public class App extends Application { new StudioWindowCoordinator(stage).showLauncher(); } + @Override + public void stop() throws Exception { + Container.shutdown(); + super.stop(); + } + public static void main(String[] args) { launch(); } diff --git a/prometeu-app/src/main/java/p/studio/AppContainer.java b/prometeu-app/src/main/java/p/studio/AppContainer.java new file mode 100644 index 00000000..99b6318d --- /dev/null +++ b/prometeu-app/src/main/java/p/studio/AppContainer.java @@ -0,0 +1,79 @@ +package p.studio; + +import com.fasterxml.jackson.databind.ObjectMapper; +import p.studio.events.StudioEventBus; +import p.studio.events.StudioPackerEventAdapter; +import p.studio.utilities.ThemeService; +import p.studio.utilities.i18n.I18nService; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +public final class AppContainer implements Container { + private final I18nService i18nService; + private final ThemeService themeService; + private final StudioEventBus studioEventBus; + private final ObjectMapper mapper; + private final Packer packer; + private final StudioBackgroundTasks backgroundTasks; + + public AppContainer() { + this.i18nService = new I18nService(); + this.themeService = new ThemeService(); + this.studioEventBus = new StudioEventBus(); + this.mapper = new ObjectMapper(); + final ExecutorService backgroundExecutor = Executors.newFixedThreadPool(2, new StudioWorkerThreadFactory()); + this.backgroundTasks = new StudioBackgroundTasks(backgroundExecutor); + final p.packer.Packer embeddedPacker = p.packer.Packer.bootstrap(new StudioPackerEventAdapter(studioEventBus)); + this.packer = new Packer(embeddedPacker.workspaceService(), embeddedPacker::close); + } + + @Override + public I18nService getI18n() { + return i18nService; + } + + @Override + public ThemeService getTheme() { + return themeService; + } + + @Override + public StudioEventBus getEventBus() { + return studioEventBus; + } + + @Override + public ObjectMapper getMapper() { + return mapper; + } + + @Override + public Packer getPacker() { + return packer; + } + + @Override + public StudioBackgroundTasks getBackgroundTasks() { + return backgroundTasks; + } + + @Override + public void shutdownContainer() { + packer.shutdown(); + backgroundTasks.shutdown(); + } + + private static final class StudioWorkerThreadFactory implements ThreadFactory { + private final AtomicInteger nextId = new AtomicInteger(1); + + @Override + public Thread newThread(Runnable runnable) { + final Thread thread = new Thread(runnable, "studio-worker-" + nextId.getAndIncrement()); + thread.setDaemon(true); + return thread; + } + } +} diff --git a/prometeu-infra/src/main/java/p/studio/utilities/events/EventBusPublisher.java b/prometeu-infra/src/main/java/p/studio/utilities/events/EventBusPublisher.java new file mode 100644 index 00000000..e91c87c5 --- /dev/null +++ b/prometeu-infra/src/main/java/p/studio/utilities/events/EventBusPublisher.java @@ -0,0 +1,5 @@ +package p.studio.utilities.events; + +public interface EventBusPublisher { + void publish(E event); +} diff --git a/prometeu-infra/src/main/java/p/studio/utilities/events/TypedEventBus.java b/prometeu-infra/src/main/java/p/studio/utilities/events/TypedEventBus.java index d57705fd..9c415e6b 100644 --- a/prometeu-infra/src/main/java/p/studio/utilities/events/TypedEventBus.java +++ b/prometeu-infra/src/main/java/p/studio/utilities/events/TypedEventBus.java @@ -7,7 +7,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; -public final class TypedEventBus { +public final class TypedEventBus implements EventBusPublisher { private final AtomicLong nextSubscriptionId = new AtomicLong(1L); private final ConcurrentMap, ConcurrentMap>> subscriptionsByType = new ConcurrentHashMap<>(); @@ -40,7 +40,8 @@ public final class TypedEventBus { }; } - public void publish(Object event) { + @Override + public void publish(E event) { Objects.requireNonNull(event, "event"); final ConcurrentMap> subscriptions = subscriptionsByType.get(event.getClass()); diff --git a/prometeu-lsp/prometeu-lsp-api/build.gradle.kts b/prometeu-lsp/prometeu-lsp-api/build.gradle.kts new file mode 100644 index 00000000..a6775d6e --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("gradle.java-library-conventions") +} diff --git a/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts b/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts new file mode 100644 index 00000000..a6775d6e --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("gradle.java-library-conventions") +} diff --git a/prometeu-packer/prometeu-packer-api/build.gradle.kts b/prometeu-packer/prometeu-packer-api/build.gradle.kts new file mode 100644 index 00000000..a6775d6e --- /dev/null +++ b/prometeu-packer/prometeu-packer-api/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("gradle.java-library-conventions") +} diff --git a/prometeu-packer/src/main/java/p/packer/api/PackerOperationStatus.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/PackerOperationStatus.java similarity index 77% rename from prometeu-packer/src/main/java/p/packer/api/PackerOperationStatus.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/PackerOperationStatus.java index 25f50786..0c9ecfc0 100644 --- a/prometeu-packer/src/main/java/p/packer/api/PackerOperationStatus.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/PackerOperationStatus.java @@ -1,4 +1,4 @@ -package p.packer.api; +package p.packer; public enum PackerOperationStatus { SUCCESS, diff --git a/prometeu-packer/src/main/java/p/packer/api/PackerProjectContext.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/PackerProjectContext.java similarity index 95% rename from prometeu-packer/src/main/java/p/packer/api/PackerProjectContext.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/PackerProjectContext.java index 3290afcc..18f7e5f6 100644 --- a/prometeu-packer/src/main/java/p/packer/api/PackerProjectContext.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/PackerProjectContext.java @@ -1,4 +1,4 @@ -package p.packer.api; +package p.packer; import java.nio.file.Path; import java.util.Objects; diff --git a/prometeu-packer/src/main/java/p/packer/api/workspace/PackerWorkspaceService.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/PackerWorkspaceService.java similarity index 68% rename from prometeu-packer/src/main/java/p/packer/api/workspace/PackerWorkspaceService.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/PackerWorkspaceService.java index 0d2fe4a7..5c9332fa 100644 --- a/prometeu-packer/src/main/java/p/packer/api/workspace/PackerWorkspaceService.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/PackerWorkspaceService.java @@ -1,13 +1,13 @@ -package p.packer.api.workspace; +package p.packer; -import p.packer.api.PackerOperationClass; +import p.packer.messages.*; public interface PackerWorkspaceService { - PackerOperationClass operationClass(); - InitWorkspaceResult initWorkspace(InitWorkspaceRequest request); ListAssetsResult listAssets(ListAssetsRequest request); GetAssetDetailsResult getAssetDetails(GetAssetDetailsRequest request); + + CreateAssetResult createAsset(CreateAssetRequest request); } diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/AssetFamilyCatalog.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/AssetFamilyCatalog.java new file mode 100644 index 00000000..83f54547 --- /dev/null +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/AssetFamilyCatalog.java @@ -0,0 +1,47 @@ +package p.packer.assets; + +import java.util.Locale; + +public enum AssetFamilyCatalog { + IMAGE_BANK("image_bank", "Image Bank"), + PALETTE_BANK("palette_bank", "Palette Bank"), + SOUND_BANK("sound_bank", "Sound Bank"), + UNKNOWN("unknown", "Unknown"); + + private final String manifestType; + private final String displayName; + + AssetFamilyCatalog(String manifestType, String displayName) { + this.manifestType = manifestType; + this.displayName = displayName; + } + + public String manifestType() { + return manifestType; + } + + public String displayName() { + return displayName; + } + + public boolean matchesQuery(String normalizedQuery) { + return manifestType.contains(normalizedQuery) + || displayName.toLowerCase(Locale.ROOT).contains(normalizedQuery); + } + + public static AssetFamilyCatalog fromManifestType(String manifestType) { + if (manifestType == null) { + return UNKNOWN; + } + final String normalized = manifestType.trim().toLowerCase(Locale.ROOT); + for (AssetFamilyCatalog candidate : values()) { + if (candidate == UNKNOWN) { + continue; + } + if (candidate.manifestType.equals(normalized)) { + return candidate; + } + } + return UNKNOWN; + } +} diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/AssetReference.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/AssetReference.java new file mode 100644 index 00000000..15d72685 --- /dev/null +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/AssetReference.java @@ -0,0 +1,52 @@ +package p.packer.assets; + +import java.util.Objects; + +public record AssetReference(String value) { + private static final String ASSET_ID_PREFIX = "asset:id:"; + private static final String ASSET_ROOT_PREFIX = "asset:root:"; + + public AssetReference { + value = Objects.requireNonNull(value, "value").trim(); + if (value.isBlank()) { + throw new IllegalArgumentException("value must not be blank"); + } + } + + public static AssetReference of(String value) { + return new AssetReference(value); + } + + public static AssetReference forAssetId(int assetId) { + if (assetId <= 0) { + throw new IllegalArgumentException("assetId must be positive"); + } + return new AssetReference(ASSET_ID_PREFIX + assetId); + } + + public static AssetReference forRelativeAssetRoot(String relativeAssetRoot) { + final String normalized = Objects.requireNonNull(relativeAssetRoot, "relativeAssetRoot").trim(); + if (normalized.isBlank()) { + throw new IllegalArgumentException("relativeAssetRoot must not be blank"); + } + return new AssetReference(ASSET_ROOT_PREFIX + normalized); + } + + public boolean isAssetIdReference() { + return value.startsWith(ASSET_ID_PREFIX); + } + + public boolean isAssetRootReference() { + return value.startsWith(ASSET_ROOT_PREFIX); + } + + public String rawValue() { + if (isAssetIdReference()) { + return value.substring(ASSET_ID_PREFIX.length()); + } + if (isAssetRootReference()) { + return value.substring(ASSET_ROOT_PREFIX.length()); + } + return value; + } +} diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/OutputCodecCatalog.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/OutputCodecCatalog.java new file mode 100644 index 00000000..b11e76b4 --- /dev/null +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/OutputCodecCatalog.java @@ -0,0 +1,37 @@ +package p.packer.assets; + +import java.util.Locale; + +public enum OutputCodecCatalog { + NONE("NONE", "None"), + UNKNOWN("UNKNOWN", "Unknown"); + + private final String manifestValue; + private final String displayName; + + OutputCodecCatalog(String manifestValue, String displayName) { + this.manifestValue = manifestValue; + this.displayName = displayName; + } + + public String manifestValue() { + return manifestValue; + } + + public String displayName() { + return displayName; + } + + public static OutputCodecCatalog fromManifestValue(String manifestValue) { + if (manifestValue == null) { + return UNKNOWN; + } + final String normalized = manifestValue.trim().toUpperCase(Locale.ROOT); + for (OutputCodecCatalog candidate : values()) { + if (candidate.manifestValue.equals(normalized)) { + return candidate; + } + } + return UNKNOWN; + } +} diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/OutputFormatCatalog.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/OutputFormatCatalog.java new file mode 100644 index 00000000..e13b54ed --- /dev/null +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/OutputFormatCatalog.java @@ -0,0 +1,68 @@ +package p.packer.assets; + +import java.util.List; +import java.util.Locale; + +public enum OutputFormatCatalog { + TILES_INDEXED_V1(AssetFamilyCatalog.IMAGE_BANK, "TILES/indexed_v1", "TILES/indexed_v1"), + PALETTE_INDEXED_V1(AssetFamilyCatalog.PALETTE_BANK, "PALETTE/indexed_v1", "PALETTE/indexed_v1"), + SOUND_BANK_V1(AssetFamilyCatalog.SOUND_BANK, "SOUND/bank_v1", "SOUND/bank_v1"), + AUDIO_PCM_V1(AssetFamilyCatalog.SOUND_BANK, "AUDIO/pcm_v1", "AUDIO/pcm_v1"), + UNKNOWN(AssetFamilyCatalog.UNKNOWN, "unknown", "Unknown"); + + private final AssetFamilyCatalog assetFamily; + private final String manifestValue; + private final String displayName; + + OutputFormatCatalog(AssetFamilyCatalog assetFamily, String manifestValue, String displayName) { + this.assetFamily = assetFamily; + this.manifestValue = manifestValue; + this.displayName = displayName; + } + + public AssetFamilyCatalog assetFamily() { + return assetFamily; + } + + public String manifestValue() { + return manifestValue; + } + + public String displayName() { + return displayName; + } + + public List availableCodecs() { + return this == UNKNOWN ? List.of() : List.of(OutputCodecCatalog.NONE); + } + + public boolean supports(OutputCodecCatalog codec) { + return availableCodecs().contains(codec); + } + + public static List supportedFor(AssetFamilyCatalog assetFamily) { + if (assetFamily == null || assetFamily == AssetFamilyCatalog.UNKNOWN) { + return List.of(); + } + return java.util.Arrays.stream(values()) + .filter(candidate -> candidate != UNKNOWN) + .filter(candidate -> candidate.assetFamily == assetFamily) + .toList(); + } + + public static OutputFormatCatalog fromManifestValue(String manifestValue) { + if (manifestValue == null) { + return UNKNOWN; + } + final String normalized = manifestValue.trim().toLowerCase(Locale.ROOT); + for (OutputFormatCatalog candidate : values()) { + if (candidate == UNKNOWN) { + continue; + } + if (candidate.manifestValue.toLowerCase(Locale.ROOT).equals(normalized)) { + return candidate; + } + } + return UNKNOWN; + } +} diff --git a/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetDetails.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerAssetDetails.java similarity index 50% rename from prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetDetails.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerAssetDetails.java index f5a03313..62325381 100644 --- a/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetDetails.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerAssetDetails.java @@ -1,6 +1,6 @@ -package p.packer.api.assets; +package p.packer.assets; -import p.packer.api.diagnostics.PackerDiagnostic; +import p.packer.diagnostics.PackerDiagnostic; import java.nio.file.Path; import java.util.List; @@ -10,14 +10,18 @@ import java.util.Objects; public record PackerAssetDetails( PackerAssetSummary summary, String outputFormat, - String outputCodec, + OutputCodecCatalog outputCodec, + List availableOutputCodecs, + Map> codecConfigurationFieldsByCodec, Map> inputsByRole, List diagnostics) { public PackerAssetDetails { Objects.requireNonNull(summary, "summary"); outputFormat = Objects.requireNonNullElse(outputFormat, "unknown").trim(); - outputCodec = Objects.requireNonNullElse(outputCodec, "unknown").trim(); + outputCodec = Objects.requireNonNullElse(outputCodec, OutputCodecCatalog.UNKNOWN); + availableOutputCodecs = List.copyOf(Objects.requireNonNull(availableOutputCodecs, "availableOutputCodecs")); + codecConfigurationFieldsByCodec = Map.copyOf(Objects.requireNonNull(codecConfigurationFieldsByCodec, "codecConfigurationFieldsByCodec")); inputsByRole = Map.copyOf(Objects.requireNonNull(inputsByRole, "inputsByRole")); diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); } diff --git a/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetIdentity.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerAssetIdentity.java similarity index 95% rename from prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetIdentity.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerAssetIdentity.java index 5e247dda..7ad625b5 100644 --- a/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetIdentity.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerAssetIdentity.java @@ -1,4 +1,4 @@ -package p.packer.api.assets; +package p.packer.assets; import java.nio.file.Path; import java.util.Objects; diff --git a/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetState.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerAssetState.java similarity index 69% rename from prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetState.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerAssetState.java index 58d2d9bc..7a7aa650 100644 --- a/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetState.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerAssetState.java @@ -1,4 +1,4 @@ -package p.packer.api.assets; +package p.packer.assets; public enum PackerAssetState { REGISTERED, diff --git a/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetSummary.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerAssetSummary.java similarity index 77% rename from prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetSummary.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerAssetSummary.java index 9dca8095..c00c1f0e 100644 --- a/prometeu-packer/src/main/java/p/packer/api/assets/PackerAssetSummary.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerAssetSummary.java @@ -1,23 +1,22 @@ -package p.packer.api.assets; +package p.packer.assets; import java.util.Objects; public record PackerAssetSummary( + AssetReference assetReference, PackerAssetIdentity identity, PackerAssetState state, PackerBuildParticipation buildParticipation, - String assetFamily, + AssetFamilyCatalog assetFamily, boolean preloadEnabled, boolean hasDiagnostics) { public PackerAssetSummary { + Objects.requireNonNull(assetReference, "assetReference"); Objects.requireNonNull(identity, "identity"); Objects.requireNonNull(state, "state"); Objects.requireNonNull(buildParticipation, "buildParticipation"); - assetFamily = Objects.requireNonNullElse(assetFamily, "unknown").trim(); - if (assetFamily.isBlank()) { - assetFamily = "unknown"; - } + assetFamily = Objects.requireNonNullElse(assetFamily, AssetFamilyCatalog.UNKNOWN); if (state == PackerAssetState.REGISTERED && identity.assetId() == null) { throw new IllegalArgumentException("registered asset must expose assetId"); } diff --git a/prometeu-packer/src/main/java/p/packer/api/assets/PackerBuildParticipation.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerBuildParticipation.java similarity index 70% rename from prometeu-packer/src/main/java/p/packer/api/assets/PackerBuildParticipation.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerBuildParticipation.java index 98bfae68..854f1e51 100644 --- a/prometeu-packer/src/main/java/p/packer/api/assets/PackerBuildParticipation.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerBuildParticipation.java @@ -1,4 +1,4 @@ -package p.packer.api.assets; +package p.packer.assets; public enum PackerBuildParticipation { INCLUDED, diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerCodecConfigurationField.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerCodecConfigurationField.java new file mode 100644 index 00000000..faf0f127 --- /dev/null +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerCodecConfigurationField.java @@ -0,0 +1,24 @@ +package p.packer.assets; + +import java.util.List; +import java.util.Objects; + +public record PackerCodecConfigurationField( + String key, + String label, + PackerCodecConfigurationFieldType fieldType, + String value, + boolean required, + List options) { + + public PackerCodecConfigurationField { + key = Objects.requireNonNull(key, "key").trim(); + label = Objects.requireNonNull(label, "label").trim(); + Objects.requireNonNull(fieldType, "fieldType"); + value = Objects.requireNonNullElse(value, "").trim(); + options = List.copyOf(Objects.requireNonNull(options, "options")); + if (key.isBlank() || label.isBlank()) { + throw new IllegalArgumentException("codec configuration field key and label must not be blank"); + } + } +} diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerCodecConfigurationFieldType.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerCodecConfigurationFieldType.java new file mode 100644 index 00000000..15a45746 --- /dev/null +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/assets/PackerCodecConfigurationFieldType.java @@ -0,0 +1,8 @@ +package p.packer.assets; + +public enum PackerCodecConfigurationFieldType { + TEXT, + BOOLEAN, + INTEGER, + ENUM +} diff --git a/prometeu-packer/src/main/java/p/packer/api/diagnostics/PackerDiagnostic.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/diagnostics/PackerDiagnostic.java similarity index 95% rename from prometeu-packer/src/main/java/p/packer/api/diagnostics/PackerDiagnostic.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/diagnostics/PackerDiagnostic.java index c6848ede..47973d5f 100644 --- a/prometeu-packer/src/main/java/p/packer/api/diagnostics/PackerDiagnostic.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/diagnostics/PackerDiagnostic.java @@ -1,4 +1,4 @@ -package p.packer.api.diagnostics; +package p.packer.diagnostics; import java.nio.file.Path; import java.util.Objects; diff --git a/prometeu-packer/src/main/java/p/packer/api/diagnostics/PackerDiagnosticCategory.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/diagnostics/PackerDiagnosticCategory.java similarity index 76% rename from prometeu-packer/src/main/java/p/packer/api/diagnostics/PackerDiagnosticCategory.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/diagnostics/PackerDiagnosticCategory.java index a2a9e6ce..e852ec34 100644 --- a/prometeu-packer/src/main/java/p/packer/api/diagnostics/PackerDiagnosticCategory.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/diagnostics/PackerDiagnosticCategory.java @@ -1,4 +1,4 @@ -package p.packer.api.diagnostics; +package p.packer.diagnostics; public enum PackerDiagnosticCategory { STRUCTURAL, diff --git a/prometeu-packer/src/main/java/p/packer/api/diagnostics/PackerDiagnosticSeverity.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/diagnostics/PackerDiagnosticSeverity.java similarity index 68% rename from prometeu-packer/src/main/java/p/packer/api/diagnostics/PackerDiagnosticSeverity.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/diagnostics/PackerDiagnosticSeverity.java index 30ad2c10..9aeee55d 100644 --- a/prometeu-packer/src/main/java/p/packer/api/diagnostics/PackerDiagnosticSeverity.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/diagnostics/PackerDiagnosticSeverity.java @@ -1,4 +1,4 @@ -package p.packer.api.diagnostics; +package p.packer.diagnostics; public enum PackerDiagnosticSeverity { INFO, diff --git a/prometeu-packer/src/main/java/p/packer/api/events/PackerEvent.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/events/PackerEvent.java similarity index 97% rename from prometeu-packer/src/main/java/p/packer/api/events/PackerEvent.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/events/PackerEvent.java index 254e951d..17382faa 100644 --- a/prometeu-packer/src/main/java/p/packer/api/events/PackerEvent.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/events/PackerEvent.java @@ -1,4 +1,4 @@ -package p.packer.api.events; +package p.packer.events; import java.time.Instant; import java.util.List; diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/events/PackerEventKind.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/events/PackerEventKind.java new file mode 100644 index 00000000..12c7848a --- /dev/null +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/events/PackerEventKind.java @@ -0,0 +1,8 @@ +package p.packer.events; + +public enum PackerEventKind { + ASSET_DISCOVERED, + DIAGNOSTICS_UPDATED, + ACTION_APPLIED, + ACTION_FAILED +} diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/events/PackerEventSink.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/events/PackerEventSink.java new file mode 100644 index 00000000..28c06890 --- /dev/null +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/events/PackerEventSink.java @@ -0,0 +1,6 @@ +package p.packer.events; + +@FunctionalInterface +public interface PackerEventSink { + void publish(PackerEvent event); +} diff --git a/prometeu-packer/src/main/java/p/packer/api/events/PackerProgress.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/events/PackerProgress.java similarity index 91% rename from prometeu-packer/src/main/java/p/packer/api/events/PackerProgress.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/events/PackerProgress.java index 9ffc19f8..7408dbab 100644 --- a/prometeu-packer/src/main/java/p/packer/api/events/PackerProgress.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/events/PackerProgress.java @@ -1,4 +1,4 @@ -package p.packer.api.events; +package p.packer.events; public record PackerProgress( double value, diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryException.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/exceptions/PackerRegistryException.java similarity index 90% rename from prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryException.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/exceptions/PackerRegistryException.java index 5a0396f3..7226aefc 100644 --- a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryException.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/exceptions/PackerRegistryException.java @@ -1,4 +1,4 @@ -package p.packer.foundation; +package p.packer.exceptions; public final class PackerRegistryException extends RuntimeException { public PackerRegistryException(String message) { diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/CreateAssetRequest.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/CreateAssetRequest.java new file mode 100644 index 00000000..659b50f6 --- /dev/null +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/CreateAssetRequest.java @@ -0,0 +1,33 @@ +package p.packer.messages; + +import p.packer.PackerProjectContext; +import p.packer.assets.AssetFamilyCatalog; +import p.packer.assets.OutputCodecCatalog; +import p.packer.assets.OutputFormatCatalog; + +import java.util.Objects; + +public record CreateAssetRequest( + PackerProjectContext project, + String assetRoot, + String assetName, + AssetFamilyCatalog assetFamily, + OutputFormatCatalog outputFormat, + OutputCodecCatalog outputCodec, + boolean preloadEnabled) { + + public CreateAssetRequest { + Objects.requireNonNull(project, "project"); + assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").trim(); + assetName = Objects.requireNonNull(assetName, "assetName").trim(); + assetFamily = Objects.requireNonNullElse(assetFamily, AssetFamilyCatalog.UNKNOWN); + outputFormat = Objects.requireNonNullElse(outputFormat, OutputFormatCatalog.UNKNOWN); + outputCodec = Objects.requireNonNullElse(outputCodec, OutputCodecCatalog.UNKNOWN); + if (assetRoot.isBlank()) { + throw new IllegalArgumentException("assetRoot must not be blank"); + } + if (assetName.isBlank()) { + throw new IllegalArgumentException("assetName must not be blank"); + } + } +} diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/CreateAssetResult.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/CreateAssetResult.java new file mode 100644 index 00000000..60564e05 --- /dev/null +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/CreateAssetResult.java @@ -0,0 +1,14 @@ +package p.packer.messages; + +import p.packer.PackerOperationStatus; +import p.packer.assets.AssetReference; + +import java.nio.file.Path; + +public record CreateAssetResult( + PackerOperationStatus status, + String summary, + AssetReference assetReference, + Path assetRoot, + Path manifestPath) { +} diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/GetAssetDetailsRequest.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/GetAssetDetailsRequest.java new file mode 100644 index 00000000..c86ef1a7 --- /dev/null +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/GetAssetDetailsRequest.java @@ -0,0 +1,16 @@ +package p.packer.messages; + +import p.packer.PackerProjectContext; +import p.packer.assets.AssetReference; + +import java.util.Objects; + +public record GetAssetDetailsRequest( + PackerProjectContext project, + AssetReference assetReference) { + + public GetAssetDetailsRequest { + Objects.requireNonNull(project, "project"); + Objects.requireNonNull(assetReference, "assetReference"); + } +} diff --git a/prometeu-packer/src/main/java/p/packer/api/workspace/GetAssetDetailsResult.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/GetAssetDetailsResult.java similarity index 79% rename from prometeu-packer/src/main/java/p/packer/api/workspace/GetAssetDetailsResult.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/GetAssetDetailsResult.java index 49584579..2d4af342 100644 --- a/prometeu-packer/src/main/java/p/packer/api/workspace/GetAssetDetailsResult.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/GetAssetDetailsResult.java @@ -1,8 +1,8 @@ -package p.packer.api.workspace; +package p.packer.messages; -import p.packer.api.PackerOperationStatus; -import p.packer.api.assets.PackerAssetDetails; -import p.packer.api.diagnostics.PackerDiagnostic; +import p.packer.PackerOperationStatus; +import p.packer.assets.PackerAssetDetails; +import p.packer.diagnostics.PackerDiagnostic; import java.util.List; import java.util.Objects; diff --git a/prometeu-packer/src/main/java/p/packer/api/workspace/InitWorkspaceRequest.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/InitWorkspaceRequest.java similarity index 72% rename from prometeu-packer/src/main/java/p/packer/api/workspace/InitWorkspaceRequest.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/InitWorkspaceRequest.java index b1354290..f4839fcc 100644 --- a/prometeu-packer/src/main/java/p/packer/api/workspace/InitWorkspaceRequest.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/InitWorkspaceRequest.java @@ -1,6 +1,6 @@ -package p.packer.api.workspace; +package p.packer.messages; -import p.packer.api.PackerProjectContext; +import p.packer.PackerProjectContext; import java.util.Objects; diff --git a/prometeu-packer/src/main/java/p/packer/api/workspace/InitWorkspaceResult.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/InitWorkspaceResult.java similarity index 85% rename from prometeu-packer/src/main/java/p/packer/api/workspace/InitWorkspaceResult.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/InitWorkspaceResult.java index 4b0ae31f..bd5e533b 100644 --- a/prometeu-packer/src/main/java/p/packer/api/workspace/InitWorkspaceResult.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/InitWorkspaceResult.java @@ -1,7 +1,7 @@ -package p.packer.api.workspace; +package p.packer.messages; -import p.packer.api.PackerOperationStatus; -import p.packer.api.diagnostics.PackerDiagnostic; +import p.packer.PackerOperationStatus; +import p.packer.diagnostics.PackerDiagnostic; import java.nio.file.Path; import java.util.List; diff --git a/prometeu-packer/src/main/java/p/packer/api/workspace/ListAssetsRequest.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/ListAssetsRequest.java similarity index 72% rename from prometeu-packer/src/main/java/p/packer/api/workspace/ListAssetsRequest.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/ListAssetsRequest.java index 68f4122e..c2a498b7 100644 --- a/prometeu-packer/src/main/java/p/packer/api/workspace/ListAssetsRequest.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/ListAssetsRequest.java @@ -1,6 +1,6 @@ -package p.packer.api.workspace; +package p.packer.messages; -import p.packer.api.PackerProjectContext; +import p.packer.PackerProjectContext; import java.util.Objects; diff --git a/prometeu-packer/src/main/java/p/packer/api/workspace/ListAssetsResult.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/ListAssetsResult.java similarity index 79% rename from prometeu-packer/src/main/java/p/packer/api/workspace/ListAssetsResult.java rename to prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/ListAssetsResult.java index 538edc0f..5f298255 100644 --- a/prometeu-packer/src/main/java/p/packer/api/workspace/ListAssetsResult.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/ListAssetsResult.java @@ -1,8 +1,8 @@ -package p.packer.api.workspace; +package p.packer.messages; -import p.packer.api.PackerOperationStatus; -import p.packer.api.assets.PackerAssetSummary; -import p.packer.api.diagnostics.PackerDiagnostic; +import p.packer.PackerOperationStatus; +import p.packer.assets.PackerAssetSummary; +import p.packer.diagnostics.PackerDiagnostic; import java.util.List; import java.util.Objects; diff --git a/prometeu-packer/build.gradle.kts b/prometeu-packer/prometeu-packer-v1/build.gradle.kts similarity index 63% rename from prometeu-packer/build.gradle.kts rename to prometeu-packer/prometeu-packer-v1/build.gradle.kts index 416c0cab..5ab69752 100644 --- a/prometeu-packer/build.gradle.kts +++ b/prometeu-packer/prometeu-packer-v1/build.gradle.kts @@ -4,4 +4,5 @@ plugins { dependencies { implementation(project(":prometeu-infra")) + implementation(project(":prometeu-packer:prometeu-packer-api")) } diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/Packer.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/Packer.java new file mode 100644 index 00000000..3602ecde --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/Packer.java @@ -0,0 +1,48 @@ +package p.packer; + +import p.packer.events.PackerEventSink; +import p.packer.services.*; + +import java.io.Closeable; +import java.util.Objects; + +public final class Packer implements Closeable { + private final PackerWorkspaceService workspaceService; + private final PackerRuntimeRegistry runtimeRegistry; + private final PackerProjectWriteCoordinator writeCoordinator; + + private Packer( + PackerWorkspaceService workspaceService, + PackerRuntimeRegistry runtimeRegistry, + PackerProjectWriteCoordinator writeCoordinator) { + this.workspaceService = Objects.requireNonNull(workspaceService, "workspaceService"); + this.runtimeRegistry = Objects.requireNonNull(runtimeRegistry, "runtimeRegistry"); + this.writeCoordinator = Objects.requireNonNull(writeCoordinator, "writeCoordinator"); + } + + public static Packer bootstrap(PackerEventSink eventSink) { + final PackerEventSink resolvedEventSink = Objects.requireNonNull(eventSink, "eventSink"); + final PackerWorkspaceFoundation workspaceFoundation = new PackerWorkspaceFoundation(); + final PackerAssetDeclarationParser declarationParser = new PackerAssetDeclarationParser(); + final PackerRuntimeRegistry runtimeRegistry = new PackerRuntimeRegistry( + new PackerRuntimeLoader(workspaceFoundation, declarationParser)); + final PackerAssetDetailsService assetDetailsService = new PackerAssetDetailsService(runtimeRegistry); + final PackerProjectWriteCoordinator writeCoordinator = new PackerProjectWriteCoordinator(); + return new Packer(new FileSystemPackerWorkspaceService( + workspaceFoundation, + assetDetailsService, + runtimeRegistry, + writeCoordinator, + resolvedEventSink), runtimeRegistry, writeCoordinator); + } + + public PackerWorkspaceService workspaceService() { + return workspaceService; + } + + @Override + public void close() { + writeCoordinator.close(); + runtimeRegistry.disposeAll(); + } +} diff --git a/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDeclaration.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetDeclaration.java similarity index 64% rename from prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDeclaration.java rename to prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetDeclaration.java index dd508ff7..826031d0 100644 --- a/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDeclaration.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetDeclaration.java @@ -1,4 +1,7 @@ -package p.packer.declarations; +package p.packer.models; + +import p.packer.assets.AssetFamilyCatalog; +import p.packer.assets.OutputCodecCatalog; import java.util.List; import java.util.Map; @@ -6,23 +9,25 @@ import java.util.Objects; public record PackerAssetDeclaration( int schemaVersion, + String assetUuid, String name, - String type, + AssetFamilyCatalog assetFamily, Map> inputsByRole, String outputFormat, - String outputCodec, + OutputCodecCatalog outputCodec, boolean preloadEnabled) { public PackerAssetDeclaration { if (schemaVersion <= 0) { throw new IllegalArgumentException("schemaVersion must be positive"); } + assetUuid = Objects.requireNonNull(assetUuid, "assetUuid").trim(); name = Objects.requireNonNull(name, "name").trim(); - type = Objects.requireNonNull(type, "type").trim(); + assetFamily = Objects.requireNonNull(assetFamily, "assetFamily"); inputsByRole = Map.copyOf(Objects.requireNonNull(inputsByRole, "inputsByRole")); outputFormat = Objects.requireNonNull(outputFormat, "outputFormat").trim(); - outputCodec = Objects.requireNonNull(outputCodec, "outputCodec").trim(); - if (name.isBlank() || type.isBlank() || outputFormat.isBlank() || outputCodec.isBlank()) { + outputCodec = Objects.requireNonNull(outputCodec, "outputCodec"); + if (assetUuid.isBlank() || name.isBlank() || outputFormat.isBlank()) { throw new IllegalArgumentException("declaration fields must not be blank"); } } diff --git a/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDeclarationParseResult.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetDeclarationParseResult.java similarity index 85% rename from prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDeclarationParseResult.java rename to prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetDeclarationParseResult.java index d6bb0a1c..ed13e598 100644 --- a/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDeclarationParseResult.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetDeclarationParseResult.java @@ -1,6 +1,6 @@ -package p.packer.declarations; +package p.packer.models; -import p.packer.api.diagnostics.PackerDiagnostic; +import p.packer.diagnostics.PackerDiagnostic; import java.util.List; import java.util.Objects; diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryEntry.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerRegistryEntry.java similarity index 96% rename from prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryEntry.java rename to prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerRegistryEntry.java index b3a539a3..b19ddc7d 100644 --- a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryEntry.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerRegistryEntry.java @@ -1,4 +1,4 @@ -package p.packer.foundation; +package p.packer.models; import java.util.Objects; diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryState.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerRegistryState.java similarity index 96% rename from prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryState.java rename to prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerRegistryState.java index 08c743b6..9371656f 100644 --- a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryState.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerRegistryState.java @@ -1,4 +1,4 @@ -package p.packer.foundation; +package p.packer.models; import java.util.Comparator; import java.util.List; diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerRuntimeAsset.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerRuntimeAsset.java new file mode 100644 index 00000000..a0e0dfa6 --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerRuntimeAsset.java @@ -0,0 +1,19 @@ +package p.packer.models; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; + +public record PackerRuntimeAsset( + Path assetRoot, + Path manifestPath, + Optional registryEntry, + PackerAssetDeclarationParseResult parsedDeclaration) { + + public PackerRuntimeAsset { + assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); + manifestPath = Objects.requireNonNull(manifestPath, "manifestPath").toAbsolutePath().normalize(); + registryEntry = Objects.requireNonNull(registryEntry, "registryEntry"); + parsedDeclaration = Objects.requireNonNull(parsedDeclaration, "parsedDeclaration"); + } +} diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerRuntimeSnapshot.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerRuntimeSnapshot.java new file mode 100644 index 00000000..9942ca8a --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerRuntimeSnapshot.java @@ -0,0 +1,18 @@ +package p.packer.models; + +import java.util.List; +import java.util.Objects; + +public record PackerRuntimeSnapshot( + long generation, + PackerRegistryState registry, + List assets) { + + public PackerRuntimeSnapshot { + if (generation <= 0L) { + throw new IllegalArgumentException("generation must be positive"); + } + registry = Objects.requireNonNull(registry, "registry"); + assets = List.copyOf(Objects.requireNonNull(assets, "assets")); + } +} diff --git a/prometeu-packer/src/main/java/p/packer/foundation/FileSystemPackerRegistryRepository.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerRegistryRepository.java similarity index 81% rename from prometeu-packer/src/main/java/p/packer/foundation/FileSystemPackerRegistryRepository.java rename to prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerRegistryRepository.java index 28d8cdce..4c80cac1 100644 --- a/prometeu-packer/src/main/java/p/packer/foundation/FileSystemPackerRegistryRepository.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerRegistryRepository.java @@ -1,9 +1,11 @@ -package p.packer.foundation; +package p.packer.services; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; -import p.packer.api.PackerProjectContext; +import p.packer.PackerProjectContext; +import p.packer.models.PackerRegistryEntry; +import p.packer.models.PackerRegistryState; import java.io.IOException; import java.nio.file.Files; @@ -29,7 +31,7 @@ public final class FileSystemPackerRegistryRepository implements PackerRegistryR final RegistryDocument document = MAPPER.readValue(registryPath.toFile(), RegistryDocument.class); final int schemaVersion = document.schemaVersion <= 0 ? REGISTRY_SCHEMA_VERSION : document.schemaVersion; if (schemaVersion != REGISTRY_SCHEMA_VERSION) { - throw new PackerRegistryException("Unsupported registry schema_version: " + schemaVersion); + throw new p.packer.exceptions.PackerRegistryException("Unsupported registry schema_version: " + schemaVersion); } final List entries = new ArrayList<>(); if (document.assets != null) { @@ -53,7 +55,7 @@ public final class FileSystemPackerRegistryRepository implements PackerRegistryR nextAssetId, entries.stream().sorted(Comparator.comparingInt(PackerRegistryEntry::assetId)).toList()); } catch (IOException exception) { - throw new PackerRegistryException("Unable to load registry: " + registryPath, exception); + throw new p.packer.exceptions.PackerRegistryException("Unable to load registry: " + registryPath, exception); } } @@ -76,7 +78,7 @@ public final class FileSystemPackerRegistryRepository implements PackerRegistryR .toList(); MAPPER.writerWithDefaultPrettyPrinter().writeValue(registryPath.toFile(), document); } catch (IOException exception) { - throw new PackerRegistryException("Unable to save registry: " + registryPath, exception); + throw new p.packer.exceptions.PackerRegistryException("Unable to save registry: " + registryPath, exception); } } @@ -90,25 +92,25 @@ public final class FileSystemPackerRegistryRepository implements PackerRegistryR final Set roots = new HashSet<>(); for (PackerRegistryEntry entry : entries) { if (!assetIds.add(entry.assetId())) { - throw new PackerRegistryException("Duplicate asset_id in registry: " + entry.assetId()); + throw new p.packer.exceptions.PackerRegistryException("Duplicate asset_id in registry: " + entry.assetId()); } if (!assetUuids.add(entry.assetUuid())) { - throw new PackerRegistryException("Duplicate asset_uuid in registry: " + entry.assetUuid()); + throw new p.packer.exceptions.PackerRegistryException("Duplicate asset_uuid in registry: " + entry.assetUuid()); } if (!roots.add(entry.root())) { - throw new PackerRegistryException("Duplicate asset root in registry: " + entry.root()); + throw new p.packer.exceptions.PackerRegistryException("Duplicate asset root in registry: " + entry.root()); } } } private String normalizeRoot(String root) { if (root == null || root.isBlank()) { - throw new PackerRegistryException("Registry asset root must not be blank"); + throw new p.packer.exceptions.PackerRegistryException("Registry asset root must not be blank"); } final String normalized = root.trim().replace('\\', '/'); final Path normalizedPath = Path.of(normalized).normalize(); if (normalizedPath.isAbsolute() || normalizedPath.startsWith("..")) { - throw new PackerRegistryException("Registry asset root is outside the trusted assets boundary: " + normalized); + throw new p.packer.exceptions.PackerRegistryException("Registry asset root is outside the trusted assets boundary: " + normalized); } return normalized; } diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java new file mode 100644 index 00000000..f413f77b --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java @@ -0,0 +1,324 @@ +package p.packer.services; + +import com.fasterxml.jackson.databind.ObjectMapper; +import p.packer.PackerOperationStatus; +import p.packer.PackerProjectContext; +import p.packer.assets.AssetFamilyCatalog; +import p.packer.assets.AssetReference; +import p.packer.assets.OutputFormatCatalog; +import p.packer.assets.PackerAssetIdentity; +import p.packer.assets.PackerAssetState; +import p.packer.assets.PackerAssetSummary; +import p.packer.assets.PackerBuildParticipation; +import p.packer.diagnostics.PackerDiagnostic; +import p.packer.diagnostics.PackerDiagnosticCategory; +import p.packer.diagnostics.PackerDiagnosticSeverity; +import p.packer.events.PackerEventKind; +import p.packer.events.PackerEventSink; +import p.packer.events.PackerProgress; +import p.packer.messages.CreateAssetRequest; +import p.packer.messages.CreateAssetResult; +import p.packer.messages.GetAssetDetailsRequest; +import p.packer.messages.GetAssetDetailsResult; +import p.packer.messages.InitWorkspaceRequest; +import p.packer.messages.InitWorkspaceResult; +import p.packer.messages.ListAssetsRequest; +import p.packer.messages.ListAssetsResult; +import p.packer.PackerWorkspaceService; +import p.packer.models.PackerAssetDeclarationParseResult; +import p.packer.models.PackerRegistryEntry; +import p.packer.models.PackerRegistryState; +import p.packer.models.PackerRuntimeAsset; +import p.packer.models.PackerRuntimeSnapshot; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Stream; + +public final class FileSystemPackerWorkspaceService implements PackerWorkspaceService { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final PackerWorkspaceFoundation workspaceFoundation; + private final PackerAssetDetailsService detailsService; + private final PackerRuntimeRegistry runtimeRegistry; + private final PackerProjectWriteCoordinator writeCoordinator; + private final PackerEventSink eventSink; + + public FileSystemPackerWorkspaceService( + PackerWorkspaceFoundation workspaceFoundation, + PackerAssetDetailsService detailsService, + PackerRuntimeRegistry runtimeRegistry, + PackerProjectWriteCoordinator writeCoordinator, + PackerEventSink eventSink) { + this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation"); + this.detailsService = Objects.requireNonNull(detailsService, "detailsService"); + this.runtimeRegistry = Objects.requireNonNull(runtimeRegistry, "runtimeRegistry"); + this.writeCoordinator = Objects.requireNonNull(writeCoordinator, "writeCoordinator"); + this.eventSink = Objects.requireNonNull(eventSink, "eventSink"); + } + + @Override + public InitWorkspaceResult initWorkspace(InitWorkspaceRequest request) { + final InitWorkspaceResult result = workspaceFoundation.initWorkspace(request); + runtimeRegistry.refresh(Objects.requireNonNull(request, "request").project()); + return result; + } + + @Override + public ListAssetsResult listAssets(ListAssetsRequest request) { + final PackerProjectContext project = Objects.requireNonNull(request, "request").project(); + final PackerRuntimeSnapshot snapshot = runtimeRegistry.getOrLoad(project).snapshot(); + final PackerOperationEventEmitter events = new PackerOperationEventEmitter(project, eventSink); + final PackerRegistryState registry = snapshot.registry(); + final Map registryByRoot = new HashMap<>(); + for (PackerRegistryEntry entry : registry.assets()) { + registryByRoot.put(PackerWorkspacePaths.assetRoot(project, entry.root()), entry); + } + + final List assets = new ArrayList<>(); + final List diagnostics = new ArrayList<>(); + final Set discoveredRoots = new HashSet<>(); + + final List runtimeAssets = snapshot.assets(); + final int total = runtimeAssets.size(); + for (int index = 0; index < runtimeAssets.size(); index += 1) { + final PackerRuntimeAsset runtimeAsset = runtimeAssets.get(index); + final Path assetRoot = runtimeAsset.assetRoot(); + final Path assetManifestPath = runtimeAsset.manifestPath(); + discoveredRoots.add(assetRoot); + final PackerRegistryEntry registryEntry = registryByRoot.get(assetRoot); + final PackerAssetDeclarationParseResult parsed = runtimeAsset.parsedDeclaration(); + diagnostics.addAll(parsed.diagnostics()); + diagnostics.addAll(identityMismatchDiagnostics(registryEntry, parsed, assetManifestPath)); + final PackerAssetSummary summary = buildSummary(project, assetRoot, registryEntry, parsed); + assets.add(summary); + events.emit( + PackerEventKind.ASSET_DISCOVERED, + "Discovered asset: " + summary.identity().assetName(), + new PackerProgress(total == 0 ? 1.0d : (index + 1) / (double) total, false), + List.of(summary.identity().assetName())); + } + + for (PackerRegistryEntry entry : registry.assets()) { + final Path registeredRoot = PackerWorkspacePaths.assetRoot(project, entry.root()); + if (!discoveredRoots.contains(registeredRoot)) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Registered asset root is missing asset.json: " + entry.root(), + registeredRoot.resolve("asset.json"), + true)); + } + } + + assets.sort(Comparator + .comparing((PackerAssetSummary asset) -> asset.identity().assetRoot().toString(), String.CASE_INSENSITIVE_ORDER) + .thenComparing(summary -> summary.identity().assetName(), String.CASE_INSENSITIVE_ORDER)); + final PackerOperationStatus status = diagnostics.stream().anyMatch(PackerDiagnostic::blocking) + ? PackerOperationStatus.PARTIAL + : PackerOperationStatus.SUCCESS; + if (!diagnostics.isEmpty()) { + events.emit(PackerEventKind.DIAGNOSTICS_UPDATED, "Asset scan diagnostics updated.", List.of()); + } + return new ListAssetsResult( + status, + "Packer asset snapshot ready.", + assets, + diagnostics); + } + + @Override + public GetAssetDetailsResult getAssetDetails(GetAssetDetailsRequest request) { + return detailsService.getAssetDetails(request); + } + + @Override + public CreateAssetResult createAsset(CreateAssetRequest request) { + final CreateAssetRequest safeRequest = Objects.requireNonNull(request, "request"); + final PackerProjectContext project = safeRequest.project(); + final PackerOperationEventEmitter events = new PackerOperationEventEmitter(project, eventSink); + + return writeCoordinator.execute(project, () -> createAssetInWriteLane(safeRequest, events)); + } + + private CreateAssetResult createAssetInWriteLane( + CreateAssetRequest request, + PackerOperationEventEmitter events) { + final PackerProjectContext project = request.project(); + workspaceFoundation.initWorkspace(new InitWorkspaceRequest(project)); + + final String relativeAssetRoot = normalizeRelativeAssetRoot(request.assetRoot()); + if (relativeAssetRoot == null) { + return failureResult(events, "Asset root must stay inside assets/ and use a non-blank relative path.", null, null, List.of()); + } + + if (request.assetFamily() == AssetFamilyCatalog.UNKNOWN) { + return failureResult(events, "Asset type is required.", null, null, List.of()); + } + if (request.outputFormat() == OutputFormatCatalog.UNKNOWN) { + return failureResult(events, "Output format is required.", null, null, List.of()); + } + if (request.outputFormat().assetFamily() != request.assetFamily()) { + return failureResult(events, "Output format is not supported for the selected asset type.", null, null, List.of(request.assetName())); + } + if (!request.outputFormat().supports(request.outputCodec())) { + return failureResult(events, "Output codec is not supported for the selected output format.", null, null, List.of(request.assetName())); + } + + final Path assetRoot = PackerWorkspacePaths.assetRoot(project, relativeAssetRoot); + if (!assetRoot.startsWith(PackerWorkspacePaths.assetsRoot(project))) { + return failureResult(events, "Asset root must stay inside assets/.", null, null, List.of()); + } + + final Path manifestPath = assetRoot.resolve("asset.json"); + try { + final PackerRegistryState registry = workspaceFoundation.loadRegistry(project); + final boolean rootAlreadyRegistered = registry.assets().stream() + .map(entry -> PackerWorkspacePaths.assetRoot(project, entry.root())) + .anyMatch(assetRoot::equals); + if (rootAlreadyRegistered) { + return failureResult(events, "Asset root is already registered.", assetRoot, manifestPath, List.of(relativeAssetRoot)); + } + + if (Files.isRegularFile(manifestPath)) { + return failureResult(events, "Asset root already contains asset.json.", assetRoot, manifestPath, List.of(relativeAssetRoot)); + } + if (Files.isDirectory(assetRoot)) { + try (Stream children = Files.list(assetRoot)) { + if (children.findAny().isPresent()) { + return failureResult(events, "Asset root already exists and is not empty.", assetRoot, manifestPath, List.of(relativeAssetRoot)); + } + } + } + + final PackerRegistryEntry entry = workspaceFoundation.allocateIdentity(project, registry, assetRoot); + Files.createDirectories(assetRoot); + writeManifest(manifestPath, request, entry.assetUuid()); + final PackerRegistryState updated = workspaceFoundation.appendAllocatedEntry(registry, entry); + workspaceFoundation.saveRegistry(project, updated); + runtimeRegistry.refresh(project); + final CreateAssetResult result = new CreateAssetResult( + PackerOperationStatus.SUCCESS, + "Asset created: " + relativeAssetRoot, + AssetReference.forAssetId(entry.assetId()), + assetRoot, + manifestPath); + events.emit(PackerEventKind.ACTION_APPLIED, result.summary(), List.of(relativeAssetRoot)); + return result; + } catch (IOException exception) { + return failureResult( + events, + "Unable to create asset: " + exception.getMessage(), + assetRoot, + manifestPath, + List.of(relativeAssetRoot)); + } + } + + private CreateAssetResult failureResult( + PackerOperationEventEmitter events, + String summary, + Path assetRoot, + Path manifestPath, + List affectedAssets) { + final CreateAssetResult result = new CreateAssetResult( + PackerOperationStatus.FAILED, + summary, + null, + assetRoot, + manifestPath); + events.emit(PackerEventKind.ACTION_FAILED, result.summary(), affectedAssets); + return result; + } + + private void writeManifest(Path manifestPath, CreateAssetRequest request, String assetUuid) throws IOException { + final Map manifest = new LinkedHashMap<>(); + manifest.put("schema_version", 1); + manifest.put("asset_uuid", assetUuid); + manifest.put("name", request.assetName()); + manifest.put("type", request.assetFamily().manifestType()); + manifest.put("inputs", Map.of()); + manifest.put("output", Map.of( + "format", request.outputFormat().manifestValue(), + "codec", request.outputCodec().manifestValue())); + manifest.put("preload", Map.of("enabled", request.preloadEnabled())); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest); + } + + private String normalizeRelativeAssetRoot(String candidate) { + final String raw = Objects.requireNonNullElse(candidate, "").trim().replace('\\', '/'); + if (raw.isBlank()) { + return null; + } + final Path normalized = Path.of(raw).normalize(); + if (normalized.isAbsolute() || normalized.startsWith("..")) { + return null; + } + final String value = normalized.toString().replace('\\', '/'); + return value.isBlank() ? null : value; + } + + private PackerAssetSummary buildSummary( + PackerProjectContext project, + Path assetRoot, + PackerRegistryEntry registryEntry, + PackerAssetDeclarationParseResult parsed) { + final String assetName = parsed.declaration() != null + ? parsed.declaration().name() + : assetRoot.getFileName().toString(); + final AssetFamilyCatalog assetFamily = parsed.declaration() != null + ? parsed.declaration().assetFamily() + : AssetFamilyCatalog.UNKNOWN; + final boolean preload = parsed.declaration() != null && parsed.declaration().preloadEnabled(); + final boolean hasDiagnostics = !parsed.diagnostics().isEmpty(); + final PackerAssetState state = registryEntry == null + ? PackerAssetState.UNREGISTERED + : PackerAssetState.REGISTERED; + final PackerBuildParticipation buildParticipation = state == PackerAssetState.REGISTERED + ? (registryEntry.includedInBuild() ? PackerBuildParticipation.INCLUDED : PackerBuildParticipation.EXCLUDED) + : PackerBuildParticipation.EXCLUDED; + return new PackerAssetSummary( + assetReferenceFor(project, assetRoot, registryEntry), + new PackerAssetIdentity( + registryEntry == null ? null : registryEntry.assetId(), + registryEntry == null + ? parsed.declaration() == null ? null : parsed.declaration().assetUuid() + : registryEntry.assetUuid(), + assetName, + assetRoot), + state, + buildParticipation, + assetFamily, + preload, + hasDiagnostics); + } + + private List identityMismatchDiagnostics( + PackerRegistryEntry registryEntry, + PackerAssetDeclarationParseResult parsed, + Path manifestPath) { + if (registryEntry == null || parsed.declaration() == null || registryEntry.assetUuid().equals(parsed.declaration().assetUuid())) { + return List.of(); + } + return List.of(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Field 'asset_uuid' does not match the registered asset identity.", + manifestPath, + true)); + } + + private AssetReference assetReferenceFor( + PackerProjectContext project, + Path assetRoot, + PackerRegistryEntry registryEntry) { + if (registryEntry != null) { + return AssetReference.forAssetId(registryEntry.assetId()); + } + return AssetReference.forRelativeAssetRoot(PackerWorkspacePaths.assetsRoot(project) + .relativize(assetRoot.toAbsolutePath().normalize()) + .toString() + .replace('\\', '/')); + } +} diff --git a/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDeclarationParser.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDeclarationParser.java similarity index 73% rename from prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDeclarationParser.java rename to prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDeclarationParser.java index 1e2494cb..08f968b0 100644 --- a/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDeclarationParser.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDeclarationParser.java @@ -1,10 +1,14 @@ -package p.packer.declarations; +package p.packer.services; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import p.packer.api.diagnostics.PackerDiagnostic; -import p.packer.api.diagnostics.PackerDiagnosticCategory; -import p.packer.api.diagnostics.PackerDiagnosticSeverity; +import p.packer.assets.AssetFamilyCatalog; +import p.packer.assets.OutputCodecCatalog; +import p.packer.diagnostics.PackerDiagnostic; +import p.packer.diagnostics.PackerDiagnosticCategory; +import p.packer.diagnostics.PackerDiagnosticSeverity; +import p.packer.models.PackerAssetDeclaration; +import p.packer.models.PackerAssetDeclarationParseResult; import java.io.IOException; import java.nio.file.Path; @@ -35,11 +39,12 @@ public final class PackerAssetDeclarationParser { } final Integer schemaVersion = requiredInt(root, "schema_version", diagnostics, manifestPath); + final String assetUuid = requiredText(root, "asset_uuid", diagnostics, manifestPath); final String name = requiredText(root, "name", diagnostics, manifestPath); - final String type = requiredText(root, "type", diagnostics, manifestPath); + final AssetFamilyCatalog assetFamily = requiredAssetFamily(root, diagnostics, manifestPath); final Map> inputsByRole = requiredInputs(root.path("inputs"), diagnostics, manifestPath); final String outputFormat = requiredText(root.path("output"), "format", diagnostics, manifestPath); - final String outputCodec = requiredText(root.path("output"), "codec", diagnostics, manifestPath); + final OutputCodecCatalog outputCodec = requiredOutputCodec(root.path("output"), diagnostics, manifestPath); final Boolean preloadEnabled = requiredBoolean(root.path("preload"), "enabled", diagnostics, manifestPath); if (schemaVersion != null && schemaVersion != SUPPORTED_SCHEMA_VERSION) { @@ -58,8 +63,9 @@ public final class PackerAssetDeclarationParser { return new PackerAssetDeclarationParseResult( new PackerAssetDeclaration( schemaVersion, + assetUuid, name, - type, + assetFamily, inputsByRole, outputFormat, outputCodec, @@ -85,6 +91,24 @@ public final class PackerAssetDeclarationParser { return field.asText().trim(); } + private AssetFamilyCatalog requiredAssetFamily(JsonNode node, List diagnostics, Path manifestPath) { + final String manifestType = requiredText(node, "type", diagnostics, manifestPath); + if (manifestType == null) { + return null; + } + final AssetFamilyCatalog assetFamily = AssetFamilyCatalog.fromManifestType(manifestType); + if (assetFamily == AssetFamilyCatalog.UNKNOWN) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Field 'type' must be one of: image_bank, palette_bank, sound_bank.", + manifestPath, + true)); + return null; + } + return assetFamily; + } + private Boolean requiredBoolean(JsonNode node, String fieldName, List diagnostics, Path manifestPath) { final JsonNode field = node.path(fieldName); if (!field.isBoolean()) { @@ -94,6 +118,24 @@ public final class PackerAssetDeclarationParser { return field.booleanValue(); } + private OutputCodecCatalog requiredOutputCodec(JsonNode node, List diagnostics, Path manifestPath) { + final String codecValue = requiredText(node, "codec", diagnostics, manifestPath); + if (codecValue == null) { + return null; + } + final OutputCodecCatalog outputCodec = OutputCodecCatalog.fromManifestValue(codecValue); + if (outputCodec == OutputCodecCatalog.UNKNOWN) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Field 'codec' must be one of: NONE.", + manifestPath, + true)); + return null; + } + return outputCodec; + } + private Map> requiredInputs(JsonNode inputsNode, List diagnostics, Path manifestPath) { if (!inputsNode.isObject()) { diagnostics.add(missingOrInvalid("inputs", "object of input roles", manifestPath)); diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDetailsService.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDetailsService.java new file mode 100644 index 00000000..31a5c7f3 --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDetailsService.java @@ -0,0 +1,249 @@ +package p.packer.services; + +import p.packer.PackerOperationStatus; +import p.packer.PackerProjectContext; +import p.packer.assets.AssetFamilyCatalog; +import p.packer.assets.AssetReference; +import p.packer.assets.OutputCodecCatalog; +import p.packer.assets.PackerAssetDetails; +import p.packer.assets.PackerAssetIdentity; +import p.packer.assets.PackerAssetState; +import p.packer.assets.PackerAssetSummary; +import p.packer.assets.PackerBuildParticipation; +import p.packer.diagnostics.PackerDiagnostic; +import p.packer.diagnostics.PackerDiagnosticCategory; +import p.packer.diagnostics.PackerDiagnosticSeverity; +import p.packer.messages.GetAssetDetailsRequest; +import p.packer.messages.GetAssetDetailsResult; +import p.packer.models.PackerAssetDeclaration; +import p.packer.models.PackerAssetDeclarationParseResult; +import p.packer.models.PackerRegistryEntry; +import p.packer.models.PackerRuntimeAsset; +import p.packer.models.PackerRuntimeSnapshot; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public final class PackerAssetDetailsService { + private final PackerRuntimeRegistry runtimeRegistry; + + public PackerAssetDetailsService(PackerRuntimeRegistry runtimeRegistry) { + this.runtimeRegistry = Objects.requireNonNull(runtimeRegistry, "runtimeRegistry"); + } + + public GetAssetDetailsResult getAssetDetails(GetAssetDetailsRequest request) { + final PackerProjectContext project = Objects.requireNonNull(request, "request").project(); + final PackerRuntimeSnapshot snapshot = runtimeRegistry.getOrLoad(project).snapshot(); + final ResolvedAssetReference resolved = resolveReference(project, snapshot, request.assetReference()); + final List diagnostics = new ArrayList<>(resolved.diagnostics()); + + if (resolved.runtimeAsset().isEmpty()) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "asset.json was not found for the requested asset root.", + resolved.assetRoot().resolve("asset.json"), + true)); + return failureResult(project, request.assetReference(), resolved, diagnostics); + } + + final PackerRuntimeAsset runtimeAsset = resolved.runtimeAsset().get(); + final Path manifestPath = runtimeAsset.manifestPath(); + final PackerAssetDeclarationParseResult parsed = runtimeAsset.parsedDeclaration(); + diagnostics.addAll(parsed.diagnostics()); + if (!parsed.valid()) { + return failureResult(project, request.assetReference(), resolved, diagnostics); + } + + final PackerAssetDeclaration declaration = parsed.declaration(); + diagnostics.addAll(identityMismatchDiagnostics(resolved.registryEntry(), declaration, manifestPath)); + final PackerOutputContractCatalog.OutputContractDefinition outputContract = PackerOutputContractCatalog.definitionFor( + declaration.outputFormat(), + declaration.outputCodec()); + final PackerAssetState state = resolved.registryEntry().isPresent() + ? PackerAssetState.REGISTERED + : PackerAssetState.UNREGISTERED; + final PackerAssetSummary summary = new PackerAssetSummary( + canonicalReference(project, resolved.assetRoot(), resolved.registryEntry()), + new PackerAssetIdentity( + resolved.registryEntry().map(PackerRegistryEntry::assetId).orElse(null), + resolved.registryEntry().map(PackerRegistryEntry::assetUuid).orElse(declaration.assetUuid()), + declaration.name(), + resolved.assetRoot()), + state, + resolved.registryEntry().map(entry -> entry.includedInBuild() + ? PackerBuildParticipation.INCLUDED + : PackerBuildParticipation.EXCLUDED).orElse(PackerBuildParticipation.EXCLUDED), + declaration.assetFamily(), + declaration.preloadEnabled(), + !diagnostics.isEmpty()); + final PackerAssetDetails details = new PackerAssetDetails( + summary, + declaration.outputFormat(), + declaration.outputCodec(), + outputContract.availableCodecs(), + outputContract.codecConfigurationFieldsByCodec(), + resolveInputs(resolved.assetRoot(), declaration.inputsByRole()), + diagnostics); + return new GetAssetDetailsResult( + diagnostics.stream().anyMatch(PackerDiagnostic::blocking) ? PackerOperationStatus.PARTIAL : PackerOperationStatus.SUCCESS, + "Asset details resolved from runtime snapshot.", + details, + diagnostics); + } + + private GetAssetDetailsResult failureResult( + PackerProjectContext project, + AssetReference requestedReference, + ResolvedAssetReference resolved, + List diagnostics) { + final PackerAssetState state = resolved.registryEntry().isPresent() + ? PackerAssetState.REGISTERED + : PackerAssetState.UNREGISTERED; + final PackerAssetSummary summary = new PackerAssetSummary( + canonicalReferenceOrRequested(project, requestedReference, resolved), + new PackerAssetIdentity( + resolved.registryEntry().map(PackerRegistryEntry::assetId).orElse(null), + resolved.registryEntry().map(PackerRegistryEntry::assetUuid).orElse(null), + resolved.assetRoot().getFileName().toString(), + resolved.assetRoot()), + state, + resolved.registryEntry().map(entry -> entry.includedInBuild() + ? PackerBuildParticipation.INCLUDED + : PackerBuildParticipation.EXCLUDED).orElse(PackerBuildParticipation.EXCLUDED), + AssetFamilyCatalog.UNKNOWN, + false, + true); + final PackerAssetDetails details = new PackerAssetDetails( + summary, + "unknown", + OutputCodecCatalog.UNKNOWN, + List.of(OutputCodecCatalog.NONE), + Map.of(OutputCodecCatalog.NONE, List.of()), + Map.of(), + diagnostics); + return new GetAssetDetailsResult( + PackerOperationStatus.FAILED, + "Asset declaration is invalid or unreadable.", + details, + diagnostics); + } + + private Map> resolveInputs(Path assetRoot, Map> inputsByRole) { + final Map> resolved = new LinkedHashMap<>(); + inputsByRole.forEach((role, inputs) -> resolved.put( + role, + inputs.stream().map(input -> assetRoot.resolve(input).toAbsolutePath().normalize()).toList())); + return Map.copyOf(resolved); + } + + private List identityMismatchDiagnostics( + Optional registryEntry, + PackerAssetDeclaration declaration, + Path manifestPath) { + if (registryEntry.isEmpty() || registryEntry.get().assetUuid().equals(declaration.assetUuid())) { + return List.of(); + } + return List.of(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Field 'asset_uuid' does not match the registered asset identity.", + manifestPath, + true)); + } + + private ResolvedAssetReference resolveReference(PackerProjectContext project, PackerRuntimeSnapshot snapshot, AssetReference assetReference) { + final PackerRegistryLookup lookup = new PackerRegistryLookup(); + final String reference = Objects.requireNonNull(assetReference, "assetReference").rawValue(); + final Optional byId = parseAssetId(reference).flatMap(assetId -> lookup.findByAssetId(snapshot.registry(), assetId)); + if (byId.isPresent()) { + final Path assetRoot = PackerWorkspacePaths.assetRoot(project, byId.get().root()); + return new ResolvedAssetReference(assetRoot, byId, findRuntimeAsset(snapshot, assetRoot), List.of()); + } + + final Optional byUuid = lookup.findByAssetUuid(snapshot.registry(), reference); + if (byUuid.isPresent()) { + final Path assetRoot = PackerWorkspacePaths.assetRoot(project, byUuid.get().root()); + return new ResolvedAssetReference(assetRoot, byUuid, findRuntimeAsset(snapshot, assetRoot), List.of()); + } + + final Path candidateRoot = PackerWorkspacePaths.assetRoot(project, reference); + final Optional runtimeAsset = findRuntimeAsset(snapshot, candidateRoot); + if (runtimeAsset.isPresent()) { + return new ResolvedAssetReference(candidateRoot, lookup.findByRoot(project, snapshot.registry(), candidateRoot), runtimeAsset, List.of()); + } + + final Optional registryEntry = lookup.findByRoot(project, snapshot.registry(), candidateRoot); + if (registryEntry.isPresent()) { + return new ResolvedAssetReference(candidateRoot, registryEntry, Optional.empty(), List.of()); + } + + return new ResolvedAssetReference( + candidateRoot, + Optional.empty(), + Optional.empty(), + List.of(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Requested asset reference could not be resolved.", + candidateRoot, + true))); + } + + private AssetReference canonicalReference( + PackerProjectContext project, + Path assetRoot, + Optional registryEntry) { + if (registryEntry.isPresent()) { + return AssetReference.forAssetId(registryEntry.get().assetId()); + } + return AssetReference.forRelativeAssetRoot(PackerWorkspacePaths.assetsRoot(project) + .relativize(assetRoot.toAbsolutePath().normalize()) + .toString() + .replace('\\', '/')); + } + + private AssetReference canonicalReferenceOrRequested( + PackerProjectContext project, + AssetReference requestedReference, + ResolvedAssetReference resolved) { + if (resolved.registryEntry().isPresent() || resolved.runtimeAsset().isPresent()) { + return canonicalReference(project, resolved.assetRoot(), resolved.registryEntry()); + } + return requestedReference; + } + + private Optional parseAssetId(String reference) { + try { + return Optional.of(Integer.parseInt(reference.trim())); + } catch (NumberFormatException ignored) { + return Optional.empty(); + } + } + + private Optional findRuntimeAsset(PackerRuntimeSnapshot snapshot, Path assetRoot) { + final Path normalizedRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); + return snapshot.assets().stream() + .filter(candidate -> candidate.assetRoot().equals(normalizedRoot)) + .findFirst(); + } + + private record ResolvedAssetReference( + Path assetRoot, + Optional registryEntry, + Optional runtimeAsset, + List diagnostics) { + + private ResolvedAssetReference { + assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); + registryEntry = Objects.requireNonNull(registryEntry, "registryEntry"); + runtimeAsset = Objects.requireNonNull(runtimeAsset, "runtimeAsset"); + diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); + } + } +} diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerIdentityAllocator.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerIdentityAllocator.java similarity index 84% rename from prometeu-packer/src/main/java/p/packer/foundation/PackerIdentityAllocator.java rename to prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerIdentityAllocator.java index 0b792854..91224489 100644 --- a/prometeu-packer/src/main/java/p/packer/foundation/PackerIdentityAllocator.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerIdentityAllocator.java @@ -1,6 +1,8 @@ -package p.packer.foundation; +package p.packer.services; -import p.packer.api.PackerProjectContext; +import p.packer.PackerProjectContext; +import p.packer.models.PackerRegistryEntry; +import p.packer.models.PackerRegistryState; import java.nio.file.Path; import java.util.Comparator; @@ -19,7 +21,7 @@ public final class PackerIdentityAllocator { final String relativeRoot = PackerWorkspacePaths.relativeAssetRoot(project, normalizedAssetRoot); final boolean alreadyRegistered = state.assets().stream().anyMatch(entry -> entry.root().equals(relativeRoot)); if (alreadyRegistered) { - throw new PackerRegistryException("Asset root is already registered: " + relativeRoot); + throw new p.packer.exceptions.PackerRegistryException("Asset root is already registered: " + relativeRoot); } return new PackerRegistryEntry(state.nextAssetId(), UUID.randomUUID().toString(), relativeRoot); } diff --git a/prometeu-packer/src/main/java/p/packer/events/PackerOperationEventEmitter.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerOperationEventEmitter.java similarity index 86% rename from prometeu-packer/src/main/java/p/packer/events/PackerOperationEventEmitter.java rename to prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerOperationEventEmitter.java index 1bd05dac..b1e17857 100644 --- a/prometeu-packer/src/main/java/p/packer/events/PackerOperationEventEmitter.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerOperationEventEmitter.java @@ -1,10 +1,10 @@ -package p.packer.events; +package p.packer.services; -import p.packer.api.PackerProjectContext; -import p.packer.api.events.PackerEvent; -import p.packer.api.events.PackerEventKind; -import p.packer.api.events.PackerEventSink; -import p.packer.api.events.PackerProgress; +import p.packer.PackerProjectContext; +import p.packer.events.PackerEvent; +import p.packer.events.PackerEventKind; +import p.packer.events.PackerEventSink; +import p.packer.events.PackerProgress; import java.time.Instant; import java.util.List; diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerOutputContractCatalog.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerOutputContractCatalog.java new file mode 100644 index 00000000..53166967 --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerOutputContractCatalog.java @@ -0,0 +1,62 @@ +package p.packer.services; + +import p.packer.assets.OutputCodecCatalog; +import p.packer.assets.OutputFormatCatalog; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import p.packer.assets.PackerCodecConfigurationField; + +final class PackerOutputContractCatalog { + private PackerOutputContractCatalog() { + } + + static OutputContractDefinition definitionFor(String outputFormat, OutputCodecCatalog selectedCodec) { + final List availableCodecs = availableCodecsFor(outputFormat, selectedCodec); + return new OutputContractDefinition( + availableCodecs, + availableCodecs.stream().collect(java.util.stream.Collectors.toMap( + codec -> codec, + codec -> fieldsFor(outputFormat, codec), + (left, right) -> left, + java.util.LinkedHashMap::new))); + } + + static List availableCodecsFor(String outputFormat, OutputCodecCatalog selectedCodec) { + final Set codecs = new LinkedHashSet<>(); + final OutputFormatCatalog formatCatalog = OutputFormatCatalog.fromManifestValue(outputFormat); + if (formatCatalog != OutputFormatCatalog.UNKNOWN) { + codecs.addAll(formatCatalog.availableCodecs()); + } else { + final String normalizedFormat = Objects.requireNonNullElse(outputFormat, "").trim().toUpperCase(Locale.ROOT); + if (normalizedFormat.startsWith("TILES/") + || normalizedFormat.startsWith("PALETTE/") + || normalizedFormat.startsWith("SOUND/") + || normalizedFormat.startsWith("AUDIO/")) { + codecs.add(OutputCodecCatalog.NONE); + } + } + + final OutputCodecCatalog normalizedSelectedCodec = Objects.requireNonNullElse(selectedCodec, OutputCodecCatalog.UNKNOWN); + if (normalizedSelectedCodec != OutputCodecCatalog.UNKNOWN) { + codecs.add(normalizedSelectedCodec); + } + + if (codecs.isEmpty()) { + codecs.add(OutputCodecCatalog.NONE); + } + return List.copyOf(codecs); + } + + private static List fieldsFor(String outputFormat, OutputCodecCatalog codec) { + return List.of(); + } + + record OutputContractDefinition( + List availableCodecs, + Map> codecConfigurationFieldsByCodec) { + } +} diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerProjectRuntime.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerProjectRuntime.java new file mode 100644 index 00000000..9bc05c9c --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerProjectRuntime.java @@ -0,0 +1,47 @@ +package p.packer.services; + +import p.packer.PackerProjectContext; +import p.packer.models.PackerRuntimeSnapshot; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +public final class PackerProjectRuntime { + private final PackerProjectContext project; + private final AtomicReference snapshot; + private final AtomicBoolean disposed = new AtomicBoolean(false); + + public PackerProjectRuntime(PackerProjectContext project, PackerRuntimeSnapshot initialSnapshot) { + this.project = Objects.requireNonNull(project, "project"); + this.snapshot = new AtomicReference<>(Objects.requireNonNull(initialSnapshot, "initialSnapshot")); + } + + public PackerProjectContext project() { + return project; + } + + public PackerRuntimeSnapshot snapshot() { + ensureActive(); + return snapshot.get(); + } + + public void replaceSnapshot(PackerRuntimeSnapshot nextSnapshot) { + ensureActive(); + snapshot.set(Objects.requireNonNull(nextSnapshot, "nextSnapshot")); + } + + public boolean disposed() { + return disposed.get(); + } + + public void dispose() { + disposed.set(true); + } + + private void ensureActive() { + if (disposed.get()) { + throw new IllegalStateException("Runtime has been disposed for project: " + project.projectId()); + } + } +} diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerProjectWriteCoordinator.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerProjectWriteCoordinator.java new file mode 100644 index 00000000..fe053858 --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerProjectWriteCoordinator.java @@ -0,0 +1,84 @@ +package p.packer.services; + +import p.packer.PackerProjectContext; + +import java.io.Closeable; +import java.nio.file.Path; +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public final class PackerProjectWriteCoordinator implements Closeable { + private final ConcurrentMap executors = new ConcurrentHashMap<>(); + private final AtomicInteger nextThreadId = new AtomicInteger(1); + + public Future submit(PackerProjectContext project, Callable task) { + final PackerProjectContext safeProject = Objects.requireNonNull(project, "project"); + final Callable safeTask = Objects.requireNonNull(task, "task"); + return executorFor(safeProject).submit(safeTask); + } + + public T execute(PackerProjectContext project, Callable task) { + try { + return submit(project, task).get(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Write execution was interrupted for project: " + project.projectId(), exception); + } catch (ExecutionException exception) { + final Throwable cause = exception.getCause(); + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new IllegalStateException("Write execution failed for project: " + project.projectId(), cause); + } + } + + @Override + public void close() { + executors.values().forEach(executor -> { + executor.shutdownNow(); + try { + executor.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + }); + executors.clear(); + } + + private ExecutorService executorFor(PackerProjectContext project) { + final ProjectKey key = ProjectKey.from(project); + return executors.computeIfAbsent(key, ignored -> new ThreadPoolExecutor( + 1, + 1, + 0L, + TimeUnit.MILLISECONDS, + new LinkedBlockingQueue<>(), + runnable -> { + final Thread thread = new Thread( + runnable, + "packer-write-" + project.projectId() + "-" + nextThreadId.getAndIncrement()); + thread.setDaemon(true); + return thread; + })); + } + + private record ProjectKey( + String projectId, + Path rootPath) { + + private static ProjectKey from(PackerProjectContext project) { + return new ProjectKey( + Objects.requireNonNull(project.projectId(), "projectId"), + Objects.requireNonNull(project.rootPath(), "rootPath").toAbsolutePath().normalize()); + } + } +} diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryLookup.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRegistryLookup.java similarity index 85% rename from prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryLookup.java rename to prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRegistryLookup.java index ca6d4a84..a02f8026 100644 --- a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryLookup.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRegistryLookup.java @@ -1,6 +1,8 @@ -package p.packer.foundation; +package p.packer.services; -import p.packer.api.PackerProjectContext; +import p.packer.PackerProjectContext; +import p.packer.models.PackerRegistryEntry; +import p.packer.models.PackerRegistryState; import java.nio.file.Files; import java.nio.file.Path; @@ -31,7 +33,7 @@ public final class PackerRegistryLookup { Objects.requireNonNull(entry, "entry"); final Path assetRoot = PackerWorkspacePaths.assetRoot(project, entry.root()); if (!Files.isDirectory(assetRoot)) { - throw new PackerRegistryException("Registered asset root does not exist: " + entry.root()); + throw new p.packer.exceptions.PackerRegistryException("Registered asset root does not exist: " + entry.root()); } return assetRoot; } diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryRepository.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRegistryRepository.java similarity index 62% rename from prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryRepository.java rename to prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRegistryRepository.java index 3b979c1c..2b10e775 100644 --- a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryRepository.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRegistryRepository.java @@ -1,6 +1,7 @@ -package p.packer.foundation; +package p.packer.services; -import p.packer.api.PackerProjectContext; +import p.packer.PackerProjectContext; +import p.packer.models.PackerRegistryState; public interface PackerRegistryRepository { PackerRegistryState load(PackerProjectContext project); diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRuntimeLoader.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRuntimeLoader.java new file mode 100644 index 00000000..1e09b208 --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRuntimeLoader.java @@ -0,0 +1,70 @@ +package p.packer.services; + +import p.packer.PackerProjectContext; +import p.packer.messages.InitWorkspaceRequest; +import p.packer.models.PackerRegistryEntry; +import p.packer.models.PackerRegistryState; +import p.packer.models.PackerRuntimeAsset; +import p.packer.models.PackerRuntimeSnapshot; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public final class PackerRuntimeLoader { + private final PackerWorkspaceFoundation workspaceFoundation; + private final PackerAssetDeclarationParser parser; + + public PackerRuntimeLoader( + PackerWorkspaceFoundation workspaceFoundation, + PackerAssetDeclarationParser parser) { + this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation"); + this.parser = Objects.requireNonNull(parser, "parser"); + } + + public PackerRuntimeSnapshot load(PackerProjectContext project, long generation) { + final PackerProjectContext safeProject = Objects.requireNonNull(project, "project"); + workspaceFoundation.initWorkspace(new InitWorkspaceRequest(safeProject)); + + final PackerRegistryState registry = workspaceFoundation.loadRegistry(safeProject); + final Map registryByRoot = registry.assets().stream() + .collect(Collectors.toMap( + entry -> PackerWorkspacePaths.assetRoot(safeProject, entry.root()), + entry -> entry)); + + final List assets = new ArrayList<>(); + final Path assetsRoot = PackerWorkspacePaths.assetsRoot(safeProject); + if (Files.isDirectory(assetsRoot)) { + try (Stream paths = Files.find(assetsRoot, Integer.MAX_VALUE, (path, attrs) -> + attrs.isRegularFile() && path.getFileName().toString().equals("asset.json"))) { + final List manifests = paths + .map(path -> path.toAbsolutePath().normalize()) + .sorted(Comparator.naturalOrder()) + .toList(); + for (Path manifestPath : manifests) { + final Path assetRoot = manifestPath.getParent(); + final Optional registryEntry = Optional.ofNullable(registryByRoot.get(assetRoot)); + assets.add(new PackerRuntimeAsset( + assetRoot, + manifestPath, + registryEntry, + parser.parse(manifestPath))); + } + } catch (IOException exception) { + throw new p.packer.exceptions.PackerRegistryException( + "Unable to build runtime snapshot for " + safeProject.projectId(), + exception); + } + } + + return new PackerRuntimeSnapshot(generation, registry, assets); + } +} diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRuntimeRegistry.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRuntimeRegistry.java new file mode 100644 index 00000000..2ef36ac5 --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRuntimeRegistry.java @@ -0,0 +1,71 @@ +package p.packer.services; + +import p.packer.PackerProjectContext; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicLong; + +public final class PackerRuntimeRegistry { + private final PackerRuntimeLoader loader; + private final ConcurrentMap runtimes = new ConcurrentHashMap<>(); + private final AtomicLong nextGeneration = new AtomicLong(1L); + + public PackerRuntimeRegistry(PackerRuntimeLoader loader) { + this.loader = Objects.requireNonNull(loader, "loader"); + } + + public PackerProjectRuntime getOrLoad(PackerProjectContext project) { + final PackerProjectContext safeProject = Objects.requireNonNull(project, "project"); + final ProjectKey key = ProjectKey.from(safeProject); + return runtimes.compute(key, (ignored, current) -> { + if (current != null && !current.disposed()) { + return current; + } + return new PackerProjectRuntime(safeProject, loader.load(safeProject, nextGeneration.getAndIncrement())); + }); + } + + public Optional find(PackerProjectContext project) { + return Optional.ofNullable(runtimes.get(ProjectKey.from(Objects.requireNonNull(project, "project")))); + } + + public PackerProjectRuntime refresh(PackerProjectContext project) { + final PackerProjectContext safeProject = Objects.requireNonNull(project, "project"); + final ProjectKey key = ProjectKey.from(safeProject); + return runtimes.compute(key, (ignored, current) -> { + final var snapshot = loader.load(safeProject, nextGeneration.getAndIncrement()); + if (current == null || current.disposed()) { + return new PackerProjectRuntime(safeProject, snapshot); + } + current.replaceSnapshot(snapshot); + return current; + }); + } + + public void dispose(PackerProjectContext project) { + final PackerProjectRuntime removed = runtimes.remove(ProjectKey.from(Objects.requireNonNull(project, "project"))); + if (removed != null) { + removed.dispose(); + } + } + + public void disposeAll() { + runtimes.values().forEach(PackerProjectRuntime::dispose); + runtimes.clear(); + } + + private record ProjectKey( + String projectId, + Path rootPath) { + + private static ProjectKey from(PackerProjectContext project) { + return new ProjectKey( + Objects.requireNonNull(project.projectId(), "projectId"), + Objects.requireNonNull(project.rootPath(), "rootPath").toAbsolutePath().normalize()); + } + } +} diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerWorkspaceFoundation.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerWorkspaceFoundation.java similarity index 85% rename from prometeu-packer/src/main/java/p/packer/foundation/PackerWorkspaceFoundation.java rename to prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerWorkspaceFoundation.java index cfa5d843..1a7b8aa0 100644 --- a/prometeu-packer/src/main/java/p/packer/foundation/PackerWorkspaceFoundation.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerWorkspaceFoundation.java @@ -1,9 +1,11 @@ -package p.packer.foundation; +package p.packer.services; -import p.packer.api.PackerOperationStatus; -import p.packer.api.PackerProjectContext; -import p.packer.api.workspace.InitWorkspaceRequest; -import p.packer.api.workspace.InitWorkspaceResult; +import p.packer.PackerOperationStatus; +import p.packer.PackerProjectContext; +import p.packer.messages.InitWorkspaceRequest; +import p.packer.messages.InitWorkspaceResult; +import p.packer.models.PackerRegistryEntry; +import p.packer.models.PackerRegistryState; import java.nio.file.Files; import java.util.List; @@ -40,7 +42,7 @@ public final class PackerWorkspaceFoundation { PackerWorkspacePaths.registryPath(project), List.of()); } catch (Exception exception) { - throw new PackerRegistryException("Unable to initialize workspace for " + project.projectId(), exception); + throw new p.packer.exceptions.PackerRegistryException("Unable to initialize workspace for " + project.projectId(), exception); } } diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerWorkspacePaths.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerWorkspacePaths.java similarity index 94% rename from prometeu-packer/src/main/java/p/packer/foundation/PackerWorkspacePaths.java rename to prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerWorkspacePaths.java index b22109c1..25f5676b 100644 --- a/prometeu-packer/src/main/java/p/packer/foundation/PackerWorkspacePaths.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerWorkspacePaths.java @@ -1,6 +1,6 @@ -package p.packer.foundation; +package p.packer.services; -import p.packer.api.PackerProjectContext; +import p.packer.PackerProjectContext; import java.nio.file.Path; import java.util.Objects; diff --git a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java new file mode 100644 index 00000000..4f53b513 --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java @@ -0,0 +1,231 @@ +package p.packer.services; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.packer.PackerOperationStatus; +import p.packer.PackerProjectContext; +import p.packer.assets.AssetFamilyCatalog; +import p.packer.assets.OutputCodecCatalog; +import p.packer.assets.OutputFormatCatalog; +import p.packer.assets.AssetReference; +import p.packer.assets.PackerBuildParticipation; +import p.packer.assets.PackerAssetState; +import p.packer.events.PackerEvent; +import p.packer.events.PackerEventKind; +import p.packer.messages.CreateAssetRequest; +import p.packer.messages.CreateAssetResult; +import p.packer.messages.GetAssetDetailsRequest; +import p.packer.messages.ListAssetsRequest; +import p.packer.testing.PackerFixtureLocator; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.junit.jupiter.api.Assertions.*; + +final class FileSystemPackerWorkspaceServiceTest { + @TempDir + Path tempDir; + + @Test + void listsRegisteredAndUnregisteredAssetsFromWorkspaceScan() throws Exception { + final Path projectRoot = copyFixture("workspaces/read-mixed", tempDir.resolve("mixed")); + final FileSystemPackerWorkspaceService service = service(); + + final var result = service.listAssets(new ListAssetsRequest(project(projectRoot))); + + assertEquals(PackerOperationStatus.SUCCESS, result.status()); + assertEquals(2, result.assets().size()); + assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.REGISTERED)); + assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.UNREGISTERED)); + assertTrue(result.assets().stream().allMatch(asset -> asset.identity().assetUuid() != null && !asset.identity().assetUuid().isBlank())); + } + + @Test + void surfacesMissingRegisteredRootAsStructuralDiagnostic() throws Exception { + final Path projectRoot = copyFixture("workspaces/read-missing-root", tempDir.resolve("missing-root")); + final FileSystemPackerWorkspaceService service = service(); + + final var result = service.listAssets(new ListAssetsRequest(project(projectRoot))); + + assertEquals(PackerOperationStatus.PARTIAL, result.status()); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("missing asset.json"))); + } + + @Test + void includesInvalidDeclarationsInSnapshotWithDiagnostics() throws Exception { + final Path projectRoot = copyFixture("workspaces/read-invalid", tempDir.resolve("invalid")); + final FileSystemPackerWorkspaceService service = service(); + + final var result = service.listAssets(new ListAssetsRequest(project(projectRoot))); + + assertEquals(1, result.assets().size()); + assertEquals(PackerAssetState.UNREGISTERED, result.assets().getFirst().state()); + assertEquals(PackerBuildParticipation.EXCLUDED, result.assets().getFirst().buildParticipation()); + assertTrue(result.assets().getFirst().hasDiagnostics()); + } + + @Test + void emitsDiscoveryAndDiagnosticsEventsDuringScan() throws Exception { + final Path projectRoot = copyFixture("workspaces/read-invalid", tempDir.resolve("events")); + final List events = new CopyOnWriteArrayList<>(); + final FileSystemPackerWorkspaceService service = service(events::add); + + service.listAssets(new ListAssetsRequest(project(projectRoot))); + + assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.ASSET_DISCOVERED)); + assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.DIAGNOSTICS_UPDATED)); + assertTrue(events.stream().allMatch(event -> event.sequence() >= 0L)); + } + + @Test + void createsRegisteredAssetAndWritesManifest() throws Exception { + final Path projectRoot = tempDir.resolve("created"); + final List events = new CopyOnWriteArrayList<>(); + final FileSystemPackerWorkspaceService service = service(events::add); + + final var result = service.createAsset(new CreateAssetRequest( + project(projectRoot), + "ui/new-atlas", + "new_atlas", + AssetFamilyCatalog.IMAGE_BANK, + OutputFormatCatalog.TILES_INDEXED_V1, + OutputCodecCatalog.NONE, + true)); + + assertEquals(PackerOperationStatus.SUCCESS, result.status()); + assertNotNull(result.assetReference()); + assertTrue(Files.isRegularFile(projectRoot.resolve("assets/ui/new-atlas/asset.json"))); + final String manifestJson = Files.readString(projectRoot.resolve("assets/ui/new-atlas/asset.json")); + assertTrue(manifestJson.contains("\"asset_uuid\"")); + + final var snapshot = service.listAssets(new ListAssetsRequest(project(projectRoot))); + assertEquals(1, snapshot.assets().size()); + assertEquals(PackerAssetState.REGISTERED, snapshot.assets().getFirst().state()); + assertEquals("new_atlas", snapshot.assets().getFirst().identity().assetName()); + assertNotNull(snapshot.assets().getFirst().identity().assetUuid()); + assertEquals(PackerBuildParticipation.INCLUDED, snapshot.assets().getFirst().buildParticipation()); + assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.ACTION_APPLIED)); + } + + @Test + void returnsCreatedAssetThroughRuntimeBackedDetailsWithoutRescanMismatch() throws Exception { + final Path projectRoot = tempDir.resolve("created-details"); + final FileSystemPackerWorkspaceService service = service(); + + final CreateAssetResult createResult = service.createAsset(new CreateAssetRequest( + project(projectRoot), + "ui/new-atlas", + "new_atlas", + AssetFamilyCatalog.IMAGE_BANK, + OutputFormatCatalog.TILES_INDEXED_V1, + OutputCodecCatalog.NONE, + true)); + + assertEquals(PackerOperationStatus.SUCCESS, createResult.status()); + final AssetReference assetReference = createResult.assetReference(); + assertNotNull(assetReference); + + final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest( + project(projectRoot), + assetReference)); + + assertEquals(PackerOperationStatus.SUCCESS, detailsResult.status()); + assertEquals(PackerAssetState.REGISTERED, detailsResult.details().summary().state()); + assertEquals("new_atlas", detailsResult.details().summary().identity().assetName()); + assertNotNull(detailsResult.details().summary().identity().assetUuid()); + assertTrue(detailsResult.diagnostics().isEmpty()); + } + + @Test + void rejectsUnsupportedFormatForSelectedFamily() { + final Path projectRoot = tempDir.resolve("unsupported"); + final List events = new CopyOnWriteArrayList<>(); + final FileSystemPackerWorkspaceService service = service(events::add); + + final var result = service.createAsset(new CreateAssetRequest( + project(projectRoot), + "audio/bad", + "bad_asset", + AssetFamilyCatalog.IMAGE_BANK, + OutputFormatCatalog.SOUND_BANK_V1, + OutputCodecCatalog.NONE, + false)); + + assertEquals(PackerOperationStatus.FAILED, result.status()); + assertNull(result.assetReference()); + assertTrue(result.summary().contains("not supported")); + assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.ACTION_FAILED)); + } + + @Test + void serializesConcurrentCreateAssetRequestsPerProject() throws Exception { + final Path projectRoot = tempDir.resolve("concurrent-create"); + final FileSystemPackerWorkspaceService service = service(); + try (var executor = Executors.newFixedThreadPool(2)) { + final Future first = executor.submit(() -> service.createAsset(new CreateAssetRequest( + project(projectRoot), + "ui/atlas-a", + "atlas_a", + AssetFamilyCatalog.IMAGE_BANK, + OutputFormatCatalog.TILES_INDEXED_V1, + OutputCodecCatalog.NONE, + true))); + final Future second = executor.submit(() -> service.createAsset(new CreateAssetRequest( + project(projectRoot), + "ui/atlas-b", + "atlas_b", + AssetFamilyCatalog.IMAGE_BANK, + OutputFormatCatalog.TILES_INDEXED_V1, + OutputCodecCatalog.NONE, + false))); + + assertEquals(PackerOperationStatus.SUCCESS, first.get().status()); + assertEquals(PackerOperationStatus.SUCCESS, second.get().status()); + } + + final var snapshot = service.listAssets(new ListAssetsRequest(project(projectRoot))); + assertEquals(2, snapshot.assets().size()); + assertEquals(2, new HashSet<>(snapshot.assets().stream().map(asset -> asset.identity().assetId()).toList()).size()); + } + + private PackerProjectContext project(Path root) { + return new PackerProjectContext("main", root); + } + + private FileSystemPackerWorkspaceService service() { + return service(ignored -> { + }); + } + + private FileSystemPackerWorkspaceService service(p.packer.events.PackerEventSink eventSink) { + final var foundation = new p.packer.services.PackerWorkspaceFoundation(); + final var parser = new p.packer.services.PackerAssetDeclarationParser(); + final var runtimeRegistry = new p.packer.services.PackerRuntimeRegistry(new p.packer.services.PackerRuntimeLoader(foundation, parser)); + final var detailsService = new p.packer.services.PackerAssetDetailsService(runtimeRegistry); + final var writeCoordinator = new p.packer.services.PackerProjectWriteCoordinator(); + return new FileSystemPackerWorkspaceService(foundation, detailsService, runtimeRegistry, writeCoordinator, eventSink); + } + + private Path copyFixture(String relativePath, Path targetRoot) throws Exception { + final Path sourceRoot = PackerFixtureLocator.fixtureRoot(relativePath); + try (var stream = Files.walk(sourceRoot)) { + for (Path source : stream.sorted(Comparator.naturalOrder()).toList()) { + final Path target = targetRoot.resolve(sourceRoot.relativize(source).toString()); + if (Files.isDirectory(source)) { + Files.createDirectories(target); + } else { + Files.createDirectories(target.getParent()); + Files.copy(source, target); + } + } + } + return targetRoot; + } +} diff --git a/prometeu-packer/src/test/java/p/packer/declarations/PackerAssetDeclarationParserTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerAssetDeclarationParserTest.java similarity index 67% rename from prometeu-packer/src/test/java/p/packer/declarations/PackerAssetDeclarationParserTest.java rename to prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerAssetDeclarationParserTest.java index 0d28dbd8..c98037ba 100644 --- a/prometeu-packer/src/test/java/p/packer/declarations/PackerAssetDeclarationParserTest.java +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerAssetDeclarationParserTest.java @@ -1,8 +1,10 @@ -package p.packer.declarations; +package p.packer.services; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import p.packer.api.diagnostics.PackerDiagnosticCategory; +import p.packer.assets.AssetFamilyCatalog; +import p.packer.assets.OutputCodecCatalog; +import p.packer.diagnostics.PackerDiagnosticCategory; import p.packer.testing.PackerFixtureLocator; import java.nio.file.Files; @@ -23,10 +25,11 @@ final class PackerAssetDeclarationParserTest { assertTrue(result.valid()); assertNotNull(result.declaration()); assertEquals(1, result.declaration().schemaVersion()); + assertEquals("fixture-uuid-1", result.declaration().assetUuid()); assertEquals("ui_atlas", result.declaration().name()); - assertEquals("image_bank", result.declaration().type()); + assertEquals(AssetFamilyCatalog.IMAGE_BANK, result.declaration().assetFamily()); assertEquals("TILES/indexed_v1", result.declaration().outputFormat()); - assertEquals("RAW", result.declaration().outputCodec()); + assertEquals(OutputCodecCatalog.NONE, result.declaration().outputCodec()); assertTrue(result.declaration().preloadEnabled()); } @@ -45,6 +48,7 @@ final class PackerAssetDeclarationParserTest { assertFalse(result.valid()); assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("name"))); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("asset_uuid"))); assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("format"))); } @@ -62,10 +66,11 @@ final class PackerAssetDeclarationParserTest { Files.writeString(manifest, """ { "schema_version": 1, + "asset_uuid": "uuid-outside", "name": "bad_asset", "type": "image_bank", "inputs": { "sprites": ["../outside.png"] }, - "output": { "format": "TILES/indexed_v1", "codec": "RAW" }, + "output": { "format": "TILES/indexed_v1", "codec": "NONE" }, "preload": { "enabled": true } } """); @@ -75,4 +80,25 @@ final class PackerAssetDeclarationParserTest { assertFalse(result.valid()); assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("untrusted path"))); } + + @Test + void rejectsUnknownAssetFamily() throws Exception { + final Path manifest = tempDir.resolve("asset.json"); + Files.writeString(manifest, """ + { + "schema_version": 1, + "asset_uuid": "uuid-video", + "name": "bad_asset", + "type": "video_bank", + "inputs": { "sprites": ["atlas.png"] }, + "output": { "format": "TILES/indexed_v1", "codec": "NONE" }, + "preload": { "enabled": true } + } + """); + + final var result = parser.parse(manifest); + + assertFalse(result.valid()); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("Field 'type' must be one of"))); + } } diff --git a/prometeu-packer/src/test/java/p/packer/declarations/PackerAssetDetailsServiceTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerAssetDetailsServiceTest.java similarity index 66% rename from prometeu-packer/src/test/java/p/packer/declarations/PackerAssetDetailsServiceTest.java rename to prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerAssetDetailsServiceTest.java index e14cc30e..6f6d44e6 100644 --- a/prometeu-packer/src/test/java/p/packer/declarations/PackerAssetDetailsServiceTest.java +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerAssetDetailsServiceTest.java @@ -1,18 +1,20 @@ -package p.packer.declarations; +package p.packer.services; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import p.packer.api.PackerOperationStatus; -import p.packer.api.PackerProjectContext; -import p.packer.api.assets.PackerBuildParticipation; -import p.packer.api.assets.PackerAssetState; -import p.packer.api.workspace.GetAssetDetailsRequest; +import p.packer.PackerOperationStatus; +import p.packer.PackerProjectContext; +import p.packer.assets.AssetReference; +import p.packer.assets.PackerBuildParticipation; +import p.packer.assets.OutputCodecCatalog; +import p.packer.assets.PackerAssetState; +import p.packer.messages.GetAssetDetailsRequest; import p.packer.testing.PackerFixtureLocator; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; @@ -23,37 +25,43 @@ final class PackerAssetDetailsServiceTest { @Test void returnsRegisteredDetailsForRegisteredAssetReferenceById() throws Exception { final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("managed")); - final PackerAssetDetailsService service = new PackerAssetDetailsService(); + final PackerAssetDetailsService service = service(); - final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), "1")); + final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), AssetReference.forAssetId(1))); assertEquals(PackerOperationStatus.SUCCESS, result.status()); assertEquals(PackerAssetState.REGISTERED, result.details().summary().state()); assertEquals(PackerBuildParticipation.INCLUDED, result.details().summary().buildParticipation()); + assertEquals("fixture-uuid-1", result.details().summary().identity().assetUuid()); assertEquals("ui_atlas", result.details().summary().identity().assetName()); assertEquals("TILES/indexed_v1", result.details().outputFormat()); + assertEquals(List.of(OutputCodecCatalog.NONE), result.details().availableOutputCodecs()); + assertEquals(List.of(), result.details().codecConfigurationFieldsByCodec().get(OutputCodecCatalog.NONE)); assertTrue(result.diagnostics().isEmpty()); } @Test void returnsUnregisteredDetailsForValidUnregisteredRootReference() throws Exception { final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("orphan")); - final PackerAssetDetailsService service = new PackerAssetDetailsService(); + final PackerAssetDetailsService service = service(); - final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), "orphans/ui_sounds")); + final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), AssetReference.forRelativeAssetRoot("orphans/ui_sounds"))); assertEquals(PackerOperationStatus.SUCCESS, result.status()); assertEquals(PackerAssetState.UNREGISTERED, result.details().summary().state()); assertEquals(PackerBuildParticipation.EXCLUDED, result.details().summary().buildParticipation()); + assertEquals("orphan-uuid-1", result.details().summary().identity().assetUuid()); assertEquals("ui_sounds", result.details().summary().identity().assetName()); + assertEquals(List.of(OutputCodecCatalog.NONE), result.details().availableOutputCodecs()); + assertEquals(List.of(), result.details().codecConfigurationFieldsByCodec().get(OutputCodecCatalog.NONE)); } @Test void returnsInvalidDetailsForInvalidDeclaration() throws Exception { final Path projectRoot = copyFixture("workspaces/invalid-missing-fields", tempDir.resolve("invalid")); - final PackerAssetDetailsService service = new PackerAssetDetailsService(); + final PackerAssetDetailsService service = service(); - final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), "bad")); + final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), AssetReference.forRelativeAssetRoot("bad"))); assertEquals(PackerOperationStatus.FAILED, result.status()); assertEquals(PackerAssetState.UNREGISTERED, result.details().summary().state()); @@ -63,9 +71,9 @@ final class PackerAssetDetailsServiceTest { @Test void returnsFailureWhenReferenceCannotBeResolved() { - final PackerAssetDetailsService service = new PackerAssetDetailsService(); + final PackerAssetDetailsService service = service(); - final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(tempDir.resolve("empty")), "missing/root")); + final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(tempDir.resolve("empty")), AssetReference.forRelativeAssetRoot("missing/root"))); assertEquals(PackerOperationStatus.FAILED, result.status()); assertEquals(PackerAssetState.UNREGISTERED, result.details().summary().state()); @@ -77,6 +85,12 @@ final class PackerAssetDetailsServiceTest { return new PackerProjectContext("main", root); } + private PackerAssetDetailsService service() { + final var foundation = new p.packer.services.PackerWorkspaceFoundation(); + final var parser = new PackerAssetDeclarationParser(); + return new PackerAssetDetailsService(new PackerRuntimeRegistry(new PackerRuntimeLoader(foundation, parser))); + } + private Path copyFixture(String relativePath, Path targetRoot) throws Exception { final Path sourceRoot = PackerFixtureLocator.fixtureRoot(relativePath); try (var stream = Files.walk(sourceRoot)) { diff --git a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerProjectWriteCoordinatorTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerProjectWriteCoordinatorTest.java new file mode 100644 index 00000000..a5c3ba0f --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerProjectWriteCoordinatorTest.java @@ -0,0 +1,53 @@ +package p.packer.services; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.packer.PackerProjectContext; + +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class PackerProjectWriteCoordinatorTest { + @TempDir + Path tempDir; + + private final PackerProjectWriteCoordinator coordinator = new PackerProjectWriteCoordinator(); + + @AfterEach + void cleanup() { + coordinator.close(); + } + + @Test + void serializesSameProjectWritesInSubmissionOrder() throws Exception { + final PackerProjectContext project = new PackerProjectContext("main", tempDir.resolve("main")); + final List trace = new CopyOnWriteArrayList<>(); + final CountDownLatch firstStarted = new CountDownLatch(1); + + final Future first = coordinator.submit(project, () -> { + trace.add("first-start"); + firstStarted.countDown(); + Thread.sleep(100L); + trace.add("first-end"); + return "first"; + }); + + assertTrue(firstStarted.await(2, TimeUnit.SECONDS)); + final Future second = coordinator.submit(project, () -> { + trace.add("second"); + return "second"; + }); + + assertEquals("first", first.get(2, TimeUnit.SECONDS)); + assertEquals("second", second.get(2, TimeUnit.SECONDS)); + assertEquals(List.of("first-start", "first-end", "second"), trace); + } +} diff --git a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerRuntimeRegistryTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerRuntimeRegistryTest.java new file mode 100644 index 00000000..ccc35f1d --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerRuntimeRegistryTest.java @@ -0,0 +1,104 @@ +package p.packer.services; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.packer.PackerProjectContext; +import p.packer.testing.PackerFixtureLocator; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; + +import static org.junit.jupiter.api.Assertions.*; + +final class PackerRuntimeRegistryTest { + @TempDir + Path tempDir; + + @Test + void bootstrapsOneRuntimePerProjectAndReusesItUntilRefresh() throws Exception { + final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("managed")); + final PackerRuntimeRegistry registry = runtimeRegistry(); + final PackerProjectContext project = project(projectRoot); + + final PackerProjectRuntime first = registry.getOrLoad(project); + final PackerProjectRuntime second = registry.getOrLoad(project); + + assertSame(first, second); + assertEquals(1L, first.snapshot().generation()); + assertEquals(1, first.snapshot().registry().assets().size()); + assertEquals(1, first.snapshot().assets().size()); + } + + @Test + void refreshRebuildsSnapshotWithoutReplacingRuntimeInstance() throws Exception { + final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("refresh")); + final PackerRuntimeRegistry registry = runtimeRegistry(); + final PackerProjectContext project = project(projectRoot); + final PackerProjectRuntime runtime = registry.getOrLoad(project); + + Files.createDirectories(projectRoot.resolve("assets/audio/ui_sounds")); + Files.writeString(projectRoot.resolve("assets/audio/ui_sounds/asset.json"), """ + { + "schema_version": 1, + "asset_uuid": "runtime-refresh-uuid", + "name": "ui_sounds", + "type": "audio.bank", + "inputs": {}, + "output": { + "format": "SOUND/bank_v1", + "codec": "none" + }, + "preload": { + "enabled": false + } + } + """); + + final PackerProjectRuntime refreshed = registry.refresh(project); + + assertSame(runtime, refreshed); + assertTrue(refreshed.snapshot().generation() > 1L); + assertEquals(2, refreshed.snapshot().assets().size()); + } + + @Test + void disposeMarksRuntimeInactiveAndRemovesItFromRegistry() throws Exception { + final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("dispose")); + final PackerRuntimeRegistry registry = runtimeRegistry(); + final PackerProjectContext project = project(projectRoot); + final PackerProjectRuntime runtime = registry.getOrLoad(project); + + registry.dispose(project); + + assertTrue(runtime.disposed()); + assertTrue(registry.find(project).isEmpty()); + assertThrows(IllegalStateException.class, runtime::snapshot); + } + + private PackerRuntimeRegistry runtimeRegistry() { + final var foundation = new PackerWorkspaceFoundation(); + final var parser = new PackerAssetDeclarationParser(); + return new PackerRuntimeRegistry(new PackerRuntimeLoader(foundation, parser)); + } + + private PackerProjectContext project(Path root) { + return new PackerProjectContext("main", root); + } + + private Path copyFixture(String relativePath, Path targetRoot) throws Exception { + final Path sourceRoot = PackerFixtureLocator.fixtureRoot(relativePath); + try (var stream = Files.walk(sourceRoot)) { + for (Path source : stream.sorted(Comparator.naturalOrder()).toList()) { + final Path target = targetRoot.resolve(sourceRoot.relativize(source).toString()); + if (Files.isDirectory(source)) { + Files.createDirectories(target); + } else { + Files.createDirectories(target.getParent()); + Files.copy(source, target); + } + } + } + return targetRoot; + } +} diff --git a/prometeu-packer/src/test/java/p/packer/foundation/PackerWorkspaceFoundationTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerWorkspaceFoundationTest.java similarity index 86% rename from prometeu-packer/src/test/java/p/packer/foundation/PackerWorkspaceFoundationTest.java rename to prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerWorkspaceFoundationTest.java index 07877354..4330fef5 100644 --- a/prometeu-packer/src/test/java/p/packer/foundation/PackerWorkspaceFoundationTest.java +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerWorkspaceFoundationTest.java @@ -1,9 +1,11 @@ -package p.packer.foundation; +package p.packer.services; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import p.packer.api.PackerProjectContext; -import p.packer.api.workspace.InitWorkspaceRequest; +import p.packer.PackerProjectContext; +import p.packer.messages.InitWorkspaceRequest; +import p.packer.models.PackerRegistryEntry; +import p.packer.models.PackerRegistryState; import java.nio.file.Files; import java.nio.file.Path; @@ -89,7 +91,7 @@ final class PackerWorkspaceFoundationTest { """); final FileSystemPackerRegistryRepository repository = new FileSystemPackerRegistryRepository(); - final PackerRegistryException exception = assertThrows(PackerRegistryException.class, () -> repository.load(project(projectRoot))); + final p.packer.exceptions.PackerRegistryException exception = assertThrows(p.packer.exceptions.PackerRegistryException.class, () -> repository.load(project(projectRoot))); assertTrue(exception.getMessage().contains("Duplicate asset root")); } @@ -101,7 +103,7 @@ final class PackerWorkspaceFoundationTest { Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), "{ nope "); final FileSystemPackerRegistryRepository repository = new FileSystemPackerRegistryRepository(); - final PackerRegistryException exception = assertThrows(PackerRegistryException.class, () -> repository.load(project(projectRoot))); + final p.packer.exceptions.PackerRegistryException exception = assertThrows(p.packer.exceptions.PackerRegistryException.class, () -> repository.load(project(projectRoot))); assertTrue(exception.getMessage().contains("Unable to load registry")); } @@ -119,7 +121,7 @@ final class PackerWorkspaceFoundationTest { """); final FileSystemPackerRegistryRepository repository = new FileSystemPackerRegistryRepository(); - final PackerRegistryException exception = assertThrows(PackerRegistryException.class, () -> repository.load(project(projectRoot))); + final p.packer.exceptions.PackerRegistryException exception = assertThrows(p.packer.exceptions.PackerRegistryException.class, () -> repository.load(project(projectRoot))); assertTrue(exception.getMessage().contains("Unsupported registry schema_version")); } @@ -139,7 +141,7 @@ final class PackerWorkspaceFoundationTest { """); final FileSystemPackerRegistryRepository repository = new FileSystemPackerRegistryRepository(); - final PackerRegistryException exception = assertThrows(PackerRegistryException.class, () -> repository.load(project(projectRoot))); + final p.packer.exceptions.PackerRegistryException exception = assertThrows(p.packer.exceptions.PackerRegistryException.class, () -> repository.load(project(projectRoot))); assertTrue(exception.getMessage().contains("trusted assets boundary")); } @@ -164,7 +166,7 @@ final class PackerWorkspaceFoundationTest { assertEquals(projectRoot.resolve("assets/ui/atlas").toAbsolutePath().normalize(), lookup.resolveExistingRoot(project, state.assets().getFirst())); Files.delete(projectRoot.resolve("assets/ui/atlas")); - final PackerRegistryException exception = assertThrows(PackerRegistryException.class, () -> lookup.resolveExistingRoot(project, state.assets().getFirst())); + final p.packer.exceptions.PackerRegistryException exception = assertThrows(p.packer.exceptions.PackerRegistryException.class, () -> lookup.resolveExistingRoot(project, state.assets().getFirst())); assertTrue(exception.getMessage().contains("does not exist")); } diff --git a/prometeu-packer/src/test/java/p/packer/testing/PackerFixtureCatalogTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/testing/PackerFixtureCatalogTest.java similarity index 100% rename from prometeu-packer/src/test/java/p/packer/testing/PackerFixtureCatalogTest.java rename to prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/testing/PackerFixtureCatalogTest.java diff --git a/prometeu-packer/src/test/java/p/packer/testing/PackerFixtureLocator.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/testing/PackerFixtureLocator.java similarity index 100% rename from prometeu-packer/src/test/java/p/packer/testing/PackerFixtureLocator.java rename to prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/testing/PackerFixtureLocator.java diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/empty/.gitkeep b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/empty/.gitkeep similarity index 100% rename from prometeu-packer/src/test/resources/fixtures/workspaces/empty/.gitkeep rename to prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/empty/.gitkeep diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/invalid-malformed/assets/bad/asset.json b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/invalid-malformed/assets/bad/asset.json similarity index 100% rename from prometeu-packer/src/test/resources/fixtures/workspaces/invalid-malformed/assets/bad/asset.json rename to prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/invalid-malformed/assets/bad/asset.json diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/invalid-missing-fields/assets/bad/asset.json b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/invalid-missing-fields/assets/bad/asset.json similarity index 100% rename from prometeu-packer/src/test/resources/fixtures/workspaces/invalid-missing-fields/assets/bad/asset.json rename to prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/invalid-missing-fields/assets/bad/asset.json diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/invalid-version/assets/bad/asset.json b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/invalid-version/assets/bad/asset.json similarity index 80% rename from prometeu-packer/src/test/resources/fixtures/workspaces/invalid-version/assets/bad/asset.json rename to prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/invalid-version/assets/bad/asset.json index 11465af8..8a41b1f3 100644 --- a/prometeu-packer/src/test/resources/fixtures/workspaces/invalid-version/assets/bad/asset.json +++ b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/invalid-version/assets/bad/asset.json @@ -1,5 +1,6 @@ { "schema_version": 9, + "asset_uuid": "future-uuid-1", "name": "future_asset", "type": "image_bank", "inputs": { @@ -7,7 +8,7 @@ }, "output": { "format": "TILES/indexed_v1", - "codec": "RAW" + "codec": "NONE" }, "preload": { "enabled": true diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/managed-basic/assets/.prometeu/index.json b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/managed-basic/assets/.prometeu/index.json similarity index 100% rename from prometeu-packer/src/test/resources/fixtures/workspaces/managed-basic/assets/.prometeu/index.json rename to prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/managed-basic/assets/.prometeu/index.json diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/ui/atlas/asset.json b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/managed-basic/assets/ui/atlas/asset.json similarity index 80% rename from prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/ui/atlas/asset.json rename to prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/managed-basic/assets/ui/atlas/asset.json index 66c95444..301b3bee 100644 --- a/prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/ui/atlas/asset.json +++ b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/managed-basic/assets/ui/atlas/asset.json @@ -1,5 +1,6 @@ { "schema_version": 1, + "asset_uuid": "fixture-uuid-1", "name": "ui_atlas", "type": "image_bank", "inputs": { @@ -7,7 +8,7 @@ }, "output": { "format": "TILES/indexed_v1", - "codec": "RAW" + "codec": "NONE" }, "preload": { "enabled": true diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/orphan-valid/assets/orphans/ui_sounds/asset.json b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/orphan-valid/assets/orphans/ui_sounds/asset.json similarity index 80% rename from prometeu-packer/src/test/resources/fixtures/workspaces/orphan-valid/assets/orphans/ui_sounds/asset.json rename to prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/orphan-valid/assets/orphans/ui_sounds/asset.json index 2f0f5aa3..8ee2fbae 100644 --- a/prometeu-packer/src/test/resources/fixtures/workspaces/orphan-valid/assets/orphans/ui_sounds/asset.json +++ b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/orphan-valid/assets/orphans/ui_sounds/asset.json @@ -1,5 +1,6 @@ { "schema_version": 1, + "asset_uuid": "orphan-uuid-1", "name": "ui_sounds", "type": "sound_bank", "inputs": { @@ -7,7 +8,7 @@ }, "output": { "format": "SOUND/bank_v1", - "codec": "RAW" + "codec": "NONE" }, "preload": { "enabled": false diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/read-invalid/assets/bad/asset.json b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-invalid/assets/bad/asset.json similarity index 100% rename from prometeu-packer/src/test/resources/fixtures/workspaces/read-invalid/assets/bad/asset.json rename to prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-invalid/assets/bad/asset.json diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/read-missing-root/assets/.prometeu/index.json b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-missing-root/assets/.prometeu/index.json similarity index 100% rename from prometeu-packer/src/test/resources/fixtures/workspaces/read-missing-root/assets/.prometeu/index.json rename to prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-missing-root/assets/.prometeu/index.json diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/.prometeu/index.json b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-mixed/assets/.prometeu/index.json similarity index 100% rename from prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/.prometeu/index.json rename to prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-mixed/assets/.prometeu/index.json diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/orphans/ui_sounds/asset.json b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-mixed/assets/orphans/ui_sounds/asset.json similarity index 80% rename from prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/orphans/ui_sounds/asset.json rename to prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-mixed/assets/orphans/ui_sounds/asset.json index 2f0f5aa3..1436335b 100644 --- a/prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/orphans/ui_sounds/asset.json +++ b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-mixed/assets/orphans/ui_sounds/asset.json @@ -1,5 +1,6 @@ { "schema_version": 1, + "asset_uuid": "orphan-uuid-2", "name": "ui_sounds", "type": "sound_bank", "inputs": { @@ -7,7 +8,7 @@ }, "output": { "format": "SOUND/bank_v1", - "codec": "RAW" + "codec": "NONE" }, "preload": { "enabled": false diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/managed-basic/assets/ui/atlas/asset.json b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-mixed/assets/ui/atlas/asset.json similarity index 82% rename from prometeu-packer/src/test/resources/fixtures/workspaces/managed-basic/assets/ui/atlas/asset.json rename to prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-mixed/assets/ui/atlas/asset.json index 66c95444..651ed598 100644 --- a/prometeu-packer/src/test/resources/fixtures/workspaces/managed-basic/assets/ui/atlas/asset.json +++ b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-mixed/assets/ui/atlas/asset.json @@ -1,5 +1,6 @@ { "schema_version": 1, + "asset_uuid": "uuid-1", "name": "ui_atlas", "type": "image_bank", "inputs": { @@ -7,7 +8,7 @@ }, "output": { "format": "TILES/indexed_v1", - "codec": "RAW" + "codec": "NONE" }, "preload": { "enabled": true diff --git a/prometeu-packer/src/main/java/p/packer/api/PackerOperationClass.java b/prometeu-packer/src/main/java/p/packer/api/PackerOperationClass.java deleted file mode 100644 index bd8b608c..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/PackerOperationClass.java +++ /dev/null @@ -1,7 +0,0 @@ -package p.packer.api; - -public enum PackerOperationClass { - READ_ONLY, - REGISTRY_MUTATION, - WORKSPACE_MUTATION -} diff --git a/prometeu-packer/src/main/java/p/packer/api/building/PackerBuildRequest.java b/prometeu-packer/src/main/java/p/packer/api/building/PackerBuildRequest.java deleted file mode 100644 index d90111d6..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/building/PackerBuildRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package p.packer.api.building; - -import p.packer.api.PackerProjectContext; - -import java.util.Objects; - -public record PackerBuildRequest( - PackerProjectContext project, - boolean incremental) { - - public PackerBuildRequest { - Objects.requireNonNull(project, "project"); - } -} diff --git a/prometeu-packer/src/main/java/p/packer/api/building/PackerBuildResult.java b/prometeu-packer/src/main/java/p/packer/api/building/PackerBuildResult.java deleted file mode 100644 index 0fe3a54f..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/building/PackerBuildResult.java +++ /dev/null @@ -1,28 +0,0 @@ -package p.packer.api.building; - -import p.packer.api.PackerOperationStatus; -import p.packer.api.diagnostics.PackerDiagnostic; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -public record PackerBuildResult( - PackerOperationStatus status, - String summary, - Path assetsArchive, - Map companionArtifacts, - List diagnostics) { - - public PackerBuildResult { - Objects.requireNonNull(status, "status"); - summary = Objects.requireNonNull(summary, "summary").trim(); - assetsArchive = Objects.requireNonNull(assetsArchive, "assetsArchive").toAbsolutePath().normalize(); - companionArtifacts = Map.copyOf(Objects.requireNonNull(companionArtifacts, "companionArtifacts")); - diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); - if (summary.isBlank()) { - throw new IllegalArgumentException("summary must not be blank"); - } - } -} diff --git a/prometeu-packer/src/main/java/p/packer/api/building/PackerBuildService.java b/prometeu-packer/src/main/java/p/packer/api/building/PackerBuildService.java deleted file mode 100644 index a435e27a..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/building/PackerBuildService.java +++ /dev/null @@ -1,9 +0,0 @@ -package p.packer.api.building; - -import p.packer.api.PackerOperationClass; - -public interface PackerBuildService { - PackerOperationClass operationClass(); - - PackerBuildResult build(PackerBuildRequest request); -} diff --git a/prometeu-packer/src/main/java/p/packer/api/doctor/PackerDoctorMode.java b/prometeu-packer/src/main/java/p/packer/api/doctor/PackerDoctorMode.java deleted file mode 100644 index 2e07dc3c..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/doctor/PackerDoctorMode.java +++ /dev/null @@ -1,6 +0,0 @@ -package p.packer.api.doctor; - -public enum PackerDoctorMode { - MANAGED_WORLD, - EXPANDED_WORKSPACE -} diff --git a/prometeu-packer/src/main/java/p/packer/api/doctor/PackerDoctorRequest.java b/prometeu-packer/src/main/java/p/packer/api/doctor/PackerDoctorRequest.java deleted file mode 100644 index 6aaf1f04..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/doctor/PackerDoctorRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -package p.packer.api.doctor; - -import p.packer.api.PackerProjectContext; - -import java.util.Objects; - -public record PackerDoctorRequest( - PackerProjectContext project, - PackerDoctorMode mode, - boolean includeSafeFixes) { - - public PackerDoctorRequest { - Objects.requireNonNull(project, "project"); - Objects.requireNonNull(mode, "mode"); - } -} diff --git a/prometeu-packer/src/main/java/p/packer/api/doctor/PackerDoctorResult.java b/prometeu-packer/src/main/java/p/packer/api/doctor/PackerDoctorResult.java deleted file mode 100644 index d1bb7321..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/doctor/PackerDoctorResult.java +++ /dev/null @@ -1,24 +0,0 @@ -package p.packer.api.doctor; - -import p.packer.api.PackerOperationStatus; -import p.packer.api.diagnostics.PackerDiagnostic; - -import java.util.List; -import java.util.Objects; - -public record PackerDoctorResult( - PackerOperationStatus status, - String summary, - List diagnostics, - List safeFixes) { - - public PackerDoctorResult { - Objects.requireNonNull(status, "status"); - summary = Objects.requireNonNull(summary, "summary").trim(); - diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); - safeFixes = List.copyOf(Objects.requireNonNull(safeFixes, "safeFixes")); - if (summary.isBlank()) { - throw new IllegalArgumentException("summary must not be blank"); - } - } -} diff --git a/prometeu-packer/src/main/java/p/packer/api/doctor/PackerDoctorService.java b/prometeu-packer/src/main/java/p/packer/api/doctor/PackerDoctorService.java deleted file mode 100644 index 060d4728..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/doctor/PackerDoctorService.java +++ /dev/null @@ -1,9 +0,0 @@ -package p.packer.api.doctor; - -import p.packer.api.PackerOperationClass; - -public interface PackerDoctorService { - PackerOperationClass operationClass(); - - PackerDoctorResult doctor(PackerDoctorRequest request); -} diff --git a/prometeu-packer/src/main/java/p/packer/api/events/PackerEventKind.java b/prometeu-packer/src/main/java/p/packer/api/events/PackerEventKind.java deleted file mode 100644 index ac2279b6..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/events/PackerEventKind.java +++ /dev/null @@ -1,15 +0,0 @@ -package p.packer.api.events; - -public enum PackerEventKind { - ASSET_DISCOVERED, - ASSET_CHANGED, - DIAGNOSTICS_UPDATED, - BUILD_STARTED, - BUILD_FINISHED, - CACHE_HIT, - CACHE_MISS, - PREVIEW_READY, - ACTION_APPLIED, - ACTION_FAILED, - PROGRESS_UPDATED -} diff --git a/prometeu-packer/src/main/java/p/packer/api/events/PackerEventSink.java b/prometeu-packer/src/main/java/p/packer/api/events/PackerEventSink.java deleted file mode 100644 index e9fc9313..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/events/PackerEventSink.java +++ /dev/null @@ -1,11 +0,0 @@ -package p.packer.api.events; - -@FunctionalInterface -public interface PackerEventSink { - void publish(PackerEvent event); - - static PackerEventSink noop() { - return ignored -> { - }; - } -} diff --git a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationPreview.java b/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationPreview.java deleted file mode 100644 index b9fc24ca..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationPreview.java +++ /dev/null @@ -1,42 +0,0 @@ -package p.packer.api.mutations; - -import p.packer.api.PackerOperationStatus; -import p.packer.api.diagnostics.PackerDiagnostic; - -import java.nio.file.Path; -import java.util.List; -import java.util.Objects; - -public record PackerMutationPreview( - PackerOperationStatus status, - String summary, - String operationId, - PackerMutationRequest request, - List proposedActions, - List diagnostics, - List blockers, - List warnings, - List safeFixes, - boolean highRisk, - Path targetAssetRoot) { - - public PackerMutationPreview { - Objects.requireNonNull(status, "status"); - summary = Objects.requireNonNull(summary, "summary").trim(); - operationId = Objects.requireNonNull(operationId, "operationId").trim(); - Objects.requireNonNull(request, "request"); - proposedActions = List.copyOf(Objects.requireNonNull(proposedActions, "proposedActions")); - diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); - blockers = List.copyOf(Objects.requireNonNull(blockers, "blockers")); - warnings = List.copyOf(Objects.requireNonNull(warnings, "warnings")); - safeFixes = List.copyOf(Objects.requireNonNull(safeFixes, "safeFixes")); - targetAssetRoot = targetAssetRoot == null ? null : targetAssetRoot.toAbsolutePath().normalize(); - if (summary.isBlank() || operationId.isBlank()) { - throw new IllegalArgumentException("summary and operationId must not be blank"); - } - } - - public boolean canApply() { - return blockers.isEmpty(); - } -} diff --git a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationRequest.java b/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationRequest.java deleted file mode 100644 index 3d437901..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -package p.packer.api.mutations; - -import p.packer.api.PackerProjectContext; - -import java.nio.file.Path; -import java.util.Objects; - -public record PackerMutationRequest( - PackerProjectContext project, - PackerMutationType type, - String assetReference, - Path targetRoot) { - - public PackerMutationRequest { - Objects.requireNonNull(project, "project"); - Objects.requireNonNull(type, "type"); - assetReference = Objects.requireNonNull(assetReference, "assetReference").trim(); - targetRoot = targetRoot == null ? null : targetRoot.toAbsolutePath().normalize(); - if (assetReference.isBlank()) { - throw new IllegalArgumentException("assetReference must not be blank"); - } - } -} diff --git a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationResult.java b/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationResult.java deleted file mode 100644 index 2436ce0e..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationResult.java +++ /dev/null @@ -1,26 +0,0 @@ -package p.packer.api.mutations; - -import p.packer.api.PackerOperationStatus; -import p.packer.api.diagnostics.PackerDiagnostic; - -import java.util.List; -import java.util.Objects; - -public record PackerMutationResult( - PackerOperationStatus status, - String summary, - String operationId, - List appliedActions, - List diagnostics) { - - public PackerMutationResult { - Objects.requireNonNull(status, "status"); - summary = Objects.requireNonNull(summary, "summary").trim(); - operationId = Objects.requireNonNull(operationId, "operationId").trim(); - appliedActions = List.copyOf(Objects.requireNonNull(appliedActions, "appliedActions")); - diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); - if (summary.isBlank() || operationId.isBlank()) { - throw new IllegalArgumentException("summary and operationId must not be blank"); - } - } -} diff --git a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationService.java b/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationService.java deleted file mode 100644 index 97af4c8b..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationService.java +++ /dev/null @@ -1,11 +0,0 @@ -package p.packer.api.mutations; - -import p.packer.api.PackerOperationClass; - -public interface PackerMutationService { - PackerOperationClass operationClass(); - - PackerMutationPreview preview(PackerMutationRequest request); - - PackerMutationResult apply(PackerMutationPreview preview); -} diff --git a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationType.java b/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationType.java deleted file mode 100644 index f8d20400..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerMutationType.java +++ /dev/null @@ -1,9 +0,0 @@ -package p.packer.api.mutations; - -public enum PackerMutationType { - REGISTER_ASSET, - INCLUDE_ASSET_IN_BUILD, - EXCLUDE_ASSET_FROM_BUILD, - REMOVE_ASSET, - RELOCATE_ASSET -} diff --git a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerProposedAction.java b/prometeu-packer/src/main/java/p/packer/api/mutations/PackerProposedAction.java deleted file mode 100644 index 15b04014..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/mutations/PackerProposedAction.java +++ /dev/null @@ -1,20 +0,0 @@ -package p.packer.api.mutations; - -import p.packer.api.PackerOperationClass; - -import java.util.Objects; - -public record PackerProposedAction( - PackerOperationClass operationClass, - String verb, - String target) { - - public PackerProposedAction { - Objects.requireNonNull(operationClass, "operationClass"); - verb = Objects.requireNonNull(verb, "verb").trim(); - target = Objects.requireNonNull(target, "target").trim(); - if (verb.isBlank() || target.isBlank()) { - throw new IllegalArgumentException("verb and target must not be blank"); - } - } -} diff --git a/prometeu-packer/src/main/java/p/packer/api/workspace/GetAssetDetailsRequest.java b/prometeu-packer/src/main/java/p/packer/api/workspace/GetAssetDetailsRequest.java deleted file mode 100644 index bfe30543..00000000 --- a/prometeu-packer/src/main/java/p/packer/api/workspace/GetAssetDetailsRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -package p.packer.api.workspace; - -import p.packer.api.PackerProjectContext; - -import java.util.Objects; - -public record GetAssetDetailsRequest( - PackerProjectContext project, - String assetReference) { - - public GetAssetDetailsRequest { - Objects.requireNonNull(project, "project"); - assetReference = Objects.requireNonNull(assetReference, "assetReference").trim(); - if (assetReference.isBlank()) { - throw new IllegalArgumentException("assetReference must not be blank"); - } - } -} diff --git a/prometeu-packer/src/main/java/p/packer/building/FileSystemPackerBuildService.java b/prometeu-packer/src/main/java/p/packer/building/FileSystemPackerBuildService.java deleted file mode 100644 index 2bba79c3..00000000 --- a/prometeu-packer/src/main/java/p/packer/building/FileSystemPackerBuildService.java +++ /dev/null @@ -1,267 +0,0 @@ -package p.packer.building; - -import p.packer.api.PackerOperationClass; -import p.packer.api.PackerOperationStatus; -import p.packer.api.building.PackerBuildRequest; -import p.packer.api.building.PackerBuildResult; -import p.packer.api.building.PackerBuildService; -import p.packer.api.diagnostics.PackerDiagnostic; -import p.packer.api.diagnostics.PackerDiagnosticCategory; -import p.packer.api.diagnostics.PackerDiagnosticSeverity; -import p.packer.api.events.PackerEventKind; -import p.packer.api.events.PackerEventSink; -import p.packer.api.events.PackerProgress; -import p.packer.events.PackerOperationEventEmitter; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -public final class FileSystemPackerBuildService implements PackerBuildService { - private static final byte[] MAGIC = new byte[]{'P', 'P', 'A', '1'}; - private static final int SCHEMA_VERSION = 1; - private static final int PRELUDE_SIZE = 24; - - private final PackerBuildPlanner buildPlanner; - private final PackerEventSink eventSink; - - public FileSystemPackerBuildService() { - this(new PackerBuildPlanner(), PackerEventSink.noop()); - } - - public FileSystemPackerBuildService(PackerBuildPlanner buildPlanner) { - this(buildPlanner, PackerEventSink.noop()); - } - - public FileSystemPackerBuildService(PackerBuildPlanner buildPlanner, PackerEventSink eventSink) { - this.buildPlanner = Objects.requireNonNull(buildPlanner, "buildPlanner"); - this.eventSink = Objects.requireNonNull(eventSink, "eventSink"); - } - - @Override - public PackerOperationClass operationClass() { - return PackerOperationClass.WORKSPACE_MUTATION; - } - - @Override - public PackerBuildResult build(PackerBuildRequest request) { - final PackerBuildRequest buildRequest = Objects.requireNonNull(request, "request"); - final PackerOperationEventEmitter events = new PackerOperationEventEmitter(buildRequest.project(), eventSink); - final Path buildDirectory = buildRequest.project().rootPath().resolve("build"); - final Path assetsArchive = buildDirectory.resolve("assets.pa").toAbsolutePath().normalize(); - final Path assetTableJson = buildDirectory.resolve("asset_table.json").toAbsolutePath().normalize(); - final Path preloadJson = buildDirectory.resolve("preload.json").toAbsolutePath().normalize(); - final Path metadataJson = buildDirectory.resolve("asset_table_metadata.json").toAbsolutePath().normalize(); - final List trustDiagnostics = new ArrayList<>(validateExistingOutputs(buildRequest, assetsArchive, metadataJson)); - if (!trustDiagnostics.isEmpty()) { - final String summary = trustDiagnostics.getFirst().message(); - events.emit(PackerEventKind.BUILD_FINISHED, summary, new PackerProgress(1.0d, false), List.of()); - return new PackerBuildResult( - PackerOperationStatus.FAILED, - summary, - assetsArchive, - Map.of(), - trustDiagnostics); - } - events.emit(PackerEventKind.BUILD_STARTED, "Build started.", new PackerProgress(0.0d, false), List.of()); - final PackerBuildPlanResult planResult = buildPlanner.plan(buildRequest.project()); - if (planResult.plan() == null) { - events.emit(PackerEventKind.BUILD_FINISHED, planResult.summary(), new PackerProgress(1.0d, false), List.of()); - return new PackerBuildResult( - PackerOperationStatus.FAILED, - planResult.summary(), - assetsArchive, - Map.of(), - planResult.diagnostics()); - } - - try { - Files.createDirectories(buildDirectory); - final String previousCacheKey = loadPreviousCacheKey(metadataJson); - events.emit( - previousCacheKey != null && previousCacheKey.equals(planResult.plan().cacheKey()) ? PackerEventKind.CACHE_HIT : PackerEventKind.CACHE_MISS, - previousCacheKey != null && previousCacheKey.equals(planResult.plan().cacheKey()) ? "Build cache hit." : "Build cache miss.", - List.of()); - final EmittedArchive archive = emitArchive(planResult.plan()); - events.emit(PackerEventKind.PROGRESS_UPDATED, "Build archive prepared.", new PackerProgress(0.5d, false), List.of()); - Files.write(assetsArchive, archive.bytes()); - Files.writeString(assetTableJson, archive.assetTableJson(), StandardCharsets.UTF_8); - Files.writeString(preloadJson, archive.preloadJson(), StandardCharsets.UTF_8); - Files.writeString(metadataJson, archive.metadataJson(), StandardCharsets.UTF_8); - events.emit(PackerEventKind.BUILD_FINISHED, "Build finished.", new PackerProgress(1.0d, false), List.of()); - return new PackerBuildResult( - planResult.status(), - "Build emitted " + planResult.plan().assets().size() + " assets.", - assetsArchive, - Map.of( - "build/asset_table.json", assetTableJson, - "build/preload.json", preloadJson, - "build/asset_table_metadata.json", metadataJson), - planResult.diagnostics()); - } catch (IOException exception) { - throw new UncheckedIOException(exception); - } - } - - private EmittedArchive emitArchive(PackerBuildPlan plan) throws IOException { - final List> assetTable = new ArrayList<>(); - final ByteArrayOutputStream payloadStream = new ByteArrayOutputStream(); - int payloadOffset = 0; - for (PackerPlannedAsset asset : plan.assets()) { - final ByteArrayOutputStream assetPayload = new ByteArrayOutputStream(); - for (PackerPlannedInput input : asset.inputs()) { - assetPayload.write(Files.readAllBytes(input.absolutePath())); - } - final byte[] assetBytes = assetPayload.toByteArray(); - payloadStream.write(assetBytes); - - final Map row = new LinkedHashMap<>(); - row.put("asset_family", asset.assetFamily()); - row.put("asset_id", asset.assetId()); - row.put("asset_name", asset.assetName()); - row.put("asset_uuid", asset.assetUuid()); - row.put("output_codec", asset.outputCodec()); - row.put("output_format", asset.outputFormat()); - row.put("payload_length", assetBytes.length); - row.put("payload_offset", payloadOffset); - row.put("relative_root", asset.relativeRoot()); - assetTable.add(row); - payloadOffset += assetBytes.length; - } - - final List> preload = plan.assets().stream() - .filter(PackerPlannedAsset::preload) - .map(asset -> { - final Map row = new LinkedHashMap<>(); - row.put("asset_id", asset.assetId()); - row.put("asset_name", asset.assetName()); - row.put("asset_uuid", asset.assetUuid()); - return row; - }) - .toList(); - - final String assetTableJson = PackerCanonicalJson.write(assetTable); - final String preloadJson = PackerCanonicalJson.write(preload); - final String headerJson = PackerCanonicalJson.write(Map.of( - "asset_table", assetTable, - "preload", preload)); - final byte[] headerBytes = headerJson.getBytes(StandardCharsets.UTF_8); - final byte[] payloadBytes = payloadStream.toByteArray(); - final byte[] prelude = ByteBuffer.allocate(PRELUDE_SIZE) - .order(ByteOrder.BIG_ENDIAN) - .put(MAGIC) - .putInt(SCHEMA_VERSION) - .putInt(headerBytes.length) - .putInt(PRELUDE_SIZE + headerBytes.length) - .putInt(0) - .putInt(0) - .array(); - - final byte[] archiveBytes = new byte[prelude.length + headerBytes.length + payloadBytes.length]; - System.arraycopy(prelude, 0, archiveBytes, 0, prelude.length); - System.arraycopy(headerBytes, 0, archiveBytes, prelude.length, headerBytes.length); - System.arraycopy(payloadBytes, 0, archiveBytes, prelude.length + headerBytes.length, payloadBytes.length); - - final String metadataJson = PackerCanonicalJson.write(Map.of( - "asset_count", plan.assets().size(), - "cache_key", plan.cacheKey(), - "header_length", headerBytes.length, - "payload_bytes", payloadBytes.length, - "payload_offset", PRELUDE_SIZE + headerBytes.length, - "schema_version", SCHEMA_VERSION)); - return new EmittedArchive(archiveBytes, assetTableJson, preloadJson, metadataJson); - } - - @SuppressWarnings("unchecked") - private String loadPreviousCacheKey(Path metadataJson) { - if (!Files.isRegularFile(metadataJson)) { - return null; - } - try { - final Map metadata = new com.fasterxml.jackson.databind.ObjectMapper().readValue(Files.readString(metadataJson), Map.class); - final Object schema = metadata.get("schema_version"); - if (!(schema instanceof Number) || ((Number) schema).intValue() != SCHEMA_VERSION) { - throw new IllegalArgumentException("Unsupported build metadata schema_version: " + schema); - } - return metadata.getOrDefault("cache_key", "").toString(); - } catch (IOException exception) { - return null; - } - } - - private List validateExistingOutputs(PackerBuildRequest request, Path assetsArchive, Path metadataJson) { - final List diagnostics = new ArrayList<>(); - if (Files.isRegularFile(metadataJson)) { - try { - final Map metadata = new com.fasterxml.jackson.databind.ObjectMapper().readValue(Files.readString(metadataJson), Map.class); - final Object schemaVersion = metadata.get("schema_version"); - if (!(schemaVersion instanceof Number) || ((Number) schemaVersion).intValue() != SCHEMA_VERSION) { - diagnostics.add(new PackerDiagnostic( - PackerDiagnosticSeverity.ERROR, - PackerDiagnosticCategory.VERSIONING, - "Unsupported build metadata schema_version: " + schemaVersion, - metadataJson, - true)); - } - } catch (IOException exception) { - diagnostics.add(new PackerDiagnostic( - PackerDiagnosticSeverity.ERROR, - PackerDiagnosticCategory.VERSIONING, - "Unable to read build metadata for version validation.", - metadataJson, - true)); - } - } - if (request.incremental() && Files.isRegularFile(assetsArchive)) { - try { - final byte[] prelude = Files.readAllBytes(assetsArchive); - if (prelude.length < PRELUDE_SIZE) { - diagnostics.add(new PackerDiagnostic( - PackerDiagnosticSeverity.ERROR, - PackerDiagnosticCategory.VERSIONING, - "Existing assets.pa prelude is truncated.", - assetsArchive, - true)); - } else { - final ByteBuffer buffer = ByteBuffer.wrap(prelude, 0, PRELUDE_SIZE).order(ByteOrder.BIG_ENDIAN); - final byte[] magic = new byte[4]; - buffer.get(magic); - final int schemaVersion = buffer.getInt(); - if (!java.util.Arrays.equals(magic, MAGIC) || schemaVersion != SCHEMA_VERSION) { - diagnostics.add(new PackerDiagnostic( - PackerDiagnosticSeverity.ERROR, - PackerDiagnosticCategory.VERSIONING, - "Existing assets.pa uses an unsupported writer-side schema surface.", - assetsArchive, - true)); - } - } - } catch (IOException exception) { - diagnostics.add(new PackerDiagnostic( - PackerDiagnosticSeverity.ERROR, - PackerDiagnosticCategory.VERSIONING, - "Unable to inspect existing assets.pa for incremental trust validation.", - assetsArchive, - true)); - } - } - return diagnostics; - } - - private record EmittedArchive( - byte[] bytes, - String assetTableJson, - String preloadJson, - String metadataJson) { - } -} diff --git a/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlan.java b/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlan.java deleted file mode 100644 index a2d8482a..00000000 --- a/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlan.java +++ /dev/null @@ -1,21 +0,0 @@ -package p.packer.building; - -import java.util.List; -import java.util.Objects; - -public record PackerBuildPlan( - String cacheKey, - List assets, - String assetTableJson, - String preloadJson) { - - public PackerBuildPlan { - cacheKey = Objects.requireNonNull(cacheKey, "cacheKey").trim(); - assets = List.copyOf(Objects.requireNonNull(assets, "assets")); - assetTableJson = Objects.requireNonNull(assetTableJson, "assetTableJson").trim(); - preloadJson = Objects.requireNonNull(preloadJson, "preloadJson").trim(); - if (cacheKey.isBlank() || assetTableJson.isBlank() || preloadJson.isBlank()) { - throw new IllegalArgumentException("build plan fields must not be blank"); - } - } -} diff --git a/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanResult.java b/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanResult.java deleted file mode 100644 index c1b72b81..00000000 --- a/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanResult.java +++ /dev/null @@ -1,23 +0,0 @@ -package p.packer.building; - -import p.packer.api.PackerOperationStatus; -import p.packer.api.diagnostics.PackerDiagnostic; - -import java.util.List; -import java.util.Objects; - -public record PackerBuildPlanResult( - PackerOperationStatus status, - String summary, - PackerBuildPlan plan, - List diagnostics) { - - public PackerBuildPlanResult { - Objects.requireNonNull(status, "status"); - summary = Objects.requireNonNull(summary, "summary").trim(); - diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); - if (summary.isBlank()) { - throw new IllegalArgumentException("summary must not be blank"); - } - } -} diff --git a/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanner.java b/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanner.java deleted file mode 100644 index cac7768e..00000000 --- a/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanner.java +++ /dev/null @@ -1,222 +0,0 @@ -package p.packer.building; - -import p.packer.api.PackerOperationStatus; -import p.packer.api.PackerProjectContext; -import p.packer.api.assets.PackerBuildParticipation; -import p.packer.api.assets.PackerAssetState; -import p.packer.api.diagnostics.PackerDiagnostic; -import p.packer.api.diagnostics.PackerDiagnosticCategory; -import p.packer.api.diagnostics.PackerDiagnosticSeverity; -import p.packer.api.workspace.GetAssetDetailsRequest; -import p.packer.api.workspace.ListAssetsRequest; -import p.packer.declarations.PackerAssetDetailsService; -import p.packer.workspace.FileSystemPackerWorkspaceService; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HexFormat; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -public final class PackerBuildPlanner { - private final FileSystemPackerWorkspaceService workspaceService; - private final PackerAssetDetailsService detailsService; - - public PackerBuildPlanner() { - this(new FileSystemPackerWorkspaceService(), new PackerAssetDetailsService()); - } - - public PackerBuildPlanner( - FileSystemPackerWorkspaceService workspaceService, - PackerAssetDetailsService detailsService) { - this.workspaceService = Objects.requireNonNull(workspaceService, "workspaceService"); - this.detailsService = Objects.requireNonNull(detailsService, "detailsService"); - } - - public PackerBuildPlanResult plan(PackerProjectContext project) { - final var snapshot = workspaceService.listAssets(new ListAssetsRequest(Objects.requireNonNull(project, "project"))); - final List diagnostics = new ArrayList<>(snapshot.diagnostics()); - final List plannedAssets = new ArrayList<>(); - - snapshot.assets().stream() - .filter(asset -> asset.buildParticipation() == PackerBuildParticipation.INCLUDED) - .sorted(Comparator.comparingInt(asset -> asset.identity().assetId())) - .forEach(asset -> { - final var detailsResult = detailsService.getAssetDetails(new GetAssetDetailsRequest(project, Integer.toString(asset.identity().assetId()))); - diagnostics.addAll(detailsResult.diagnostics()); - if (detailsResult.details().summary().state() != PackerAssetState.REGISTERED - || detailsResult.details().summary().buildParticipation() != PackerBuildParticipation.INCLUDED) { - diagnostics.add(new PackerDiagnostic( - PackerDiagnosticSeverity.ERROR, - PackerDiagnosticCategory.BUILD, - "Registered asset is not build-eligible: " + asset.identity().assetName(), - asset.identity().assetRoot(), - true)); - return; - } - - final List plannedInputs = new ArrayList<>(); - detailsResult.details().inputsByRole().entrySet().stream() - .sorted(Map.Entry.comparingByKey()) - .forEach(entry -> entry.getValue().forEach(input -> { - if (!Files.isRegularFile(input)) { - diagnostics.add(new PackerDiagnostic( - PackerDiagnosticSeverity.ERROR, - PackerDiagnosticCategory.BUILD, - "Build input is missing: " + relativePath(project, input), - input, - true)); - return; - } - plannedInputs.add(new PackerPlannedInput( - entry.getKey(), - asset.identity().assetRoot().relativize(input).toString().replace('\\', '/'), - fileHash(input), - input)); - })); - - if (diagnostics.stream().anyMatch(diagnostic -> - diagnostic.blocking() && asset.identity().assetRoot().equals(parentRoot(diagnostic.evidencePath(), asset.identity().assetRoot())))) { - return; - } - - plannedAssets.add(new PackerPlannedAsset( - asset.identity().assetId(), - asset.identity().assetUuid(), - asset.identity().assetName(), - asset.assetFamily(), - relativePath(project, asset.identity().assetRoot()), - detailsResult.details().outputFormat(), - detailsResult.details().outputCodec(), - asset.preloadEnabled(), - plannedInputs, - asset.identity().assetRoot())); - }); - - final boolean hasBlocking = diagnostics.stream().anyMatch(PackerDiagnostic::blocking); - if (hasBlocking) { - return new PackerBuildPlanResult( - PackerOperationStatus.FAILED, - "Build plan blocked by diagnostics.", - null, - List.copyOf(diagnostics)); - } - - final String assetTableJson = buildAssetTableJson(plannedAssets); - final String preloadJson = buildPreloadJson(plannedAssets); - final String cacheKey = sha256(PackerCanonicalJson.write(Map.of( - "asset_table", parseCanonical(assetTableJson), - "preload", parseCanonical(preloadJson), - "inputs", plannedAssets.stream().map(this::cacheKeyView).toList()))); - return new PackerBuildPlanResult( - diagnostics.isEmpty() ? PackerOperationStatus.SUCCESS : PackerOperationStatus.PARTIAL, - "Build plan ready for " + plannedAssets.size() + " included assets.", - new PackerBuildPlan(cacheKey, plannedAssets, assetTableJson, preloadJson), - List.copyOf(diagnostics)); - } - - private Map cacheKeyView(PackerPlannedAsset asset) { - return Map.of( - "asset_family", asset.assetFamily(), - "asset_id", asset.assetId(), - "asset_name", asset.assetName(), - "asset_uuid", asset.assetUuid(), - "inputs", asset.inputs().stream().map(input -> Map.of( - "content_hash", input.contentHash(), - "path", input.relativePath(), - "role", input.role())).toList(), - "output_codec", asset.outputCodec(), - "output_format", asset.outputFormat(), - "preload", asset.preload(), - "root", asset.relativeRoot()); - } - - private String buildAssetTableJson(List assets) { - final List> rows = assets.stream() - .map(asset -> { - final Map row = new LinkedHashMap<>(); - row.put("asset_family", asset.assetFamily()); - row.put("asset_id", asset.assetId()); - row.put("asset_name", asset.assetName()); - row.put("asset_uuid", asset.assetUuid()); - row.put("output_codec", asset.outputCodec()); - row.put("output_format", asset.outputFormat()); - row.put("relative_root", asset.relativeRoot()); - return row; - }) - .toList(); - return PackerCanonicalJson.write(rows); - } - - private String buildPreloadJson(List assets) { - final List> rows = assets.stream() - .filter(PackerPlannedAsset::preload) - .map(asset -> { - final Map row = new LinkedHashMap<>(); - row.put("asset_id", asset.assetId()); - row.put("asset_name", asset.assetName()); - row.put("asset_uuid", asset.assetUuid()); - return row; - }) - .toList(); - return PackerCanonicalJson.write(rows); - } - - private Object parseCanonical(String json) { - try { - return new com.fasterxml.jackson.databind.ObjectMapper().readValue(json, Object.class); - } catch (IOException exception) { - throw new IllegalArgumentException("Unable to parse canonical JSON", exception); - } - } - - private Path parentRoot(Path evidencePath, Path fallbackRoot) { - if (evidencePath == null) { - return fallbackRoot; - } - Path current = evidencePath.toAbsolutePath().normalize(); - while (current != null && !current.equals(fallbackRoot)) { - if (current.getParent() != null && current.getParent().equals(fallbackRoot)) { - return fallbackRoot; - } - current = current.getParent(); - } - return fallbackRoot; - } - - private String relativePath(PackerProjectContext project, Path path) { - final Path normalized = path.toAbsolutePath().normalize(); - final Path assetsRoot = project.rootPath().resolve("assets").toAbsolutePath().normalize(); - if (normalized.startsWith(assetsRoot)) { - return assetsRoot.relativize(normalized).toString().replace('\\', '/'); - } - return normalized.toString(); - } - - private String fileHash(Path path) { - try { - return sha256(Files.readAllBytes(path)); - } catch (IOException exception) { - throw new IllegalArgumentException("Unable to hash build input: " + path, exception); - } - } - - private String sha256(String value) { - return sha256(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)); - } - - private String sha256(byte[] value) { - try { - return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); - } catch (NoSuchAlgorithmException exception) { - throw new IllegalStateException("SHA-256 is not available", exception); - } - } -} diff --git a/prometeu-packer/src/main/java/p/packer/building/PackerCanonicalJson.java b/prometeu-packer/src/main/java/p/packer/building/PackerCanonicalJson.java deleted file mode 100644 index fb86ad4c..00000000 --- a/prometeu-packer/src/main/java/p/packer/building/PackerCanonicalJson.java +++ /dev/null @@ -1,23 +0,0 @@ -package p.packer.building; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.MapperFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; - -public final class PackerCanonicalJson { - private static final ObjectMapper MAPPER = new ObjectMapper() - .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) - .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); - - private PackerCanonicalJson() { - } - - public static String write(Object value) { - try { - return MAPPER.writeValueAsString(value); - } catch (JsonProcessingException exception) { - throw new IllegalArgumentException("Unable to serialize canonical JSON value", exception); - } - } -} diff --git a/prometeu-packer/src/main/java/p/packer/building/PackerPlannedAsset.java b/prometeu-packer/src/main/java/p/packer/building/PackerPlannedAsset.java deleted file mode 100644 index 9b823742..00000000 --- a/prometeu-packer/src/main/java/p/packer/building/PackerPlannedAsset.java +++ /dev/null @@ -1,32 +0,0 @@ -package p.packer.building; - -import java.nio.file.Path; -import java.util.List; -import java.util.Objects; - -public record PackerPlannedAsset( - int assetId, - String assetUuid, - String assetName, - String assetFamily, - String relativeRoot, - String outputFormat, - String outputCodec, - boolean preload, - List inputs, - Path assetRoot) { - - public PackerPlannedAsset { - assetUuid = Objects.requireNonNull(assetUuid, "assetUuid").trim(); - assetName = Objects.requireNonNull(assetName, "assetName").trim(); - assetFamily = Objects.requireNonNull(assetFamily, "assetFamily").trim(); - relativeRoot = Objects.requireNonNull(relativeRoot, "relativeRoot").trim(); - outputFormat = Objects.requireNonNull(outputFormat, "outputFormat").trim(); - outputCodec = Objects.requireNonNull(outputCodec, "outputCodec").trim(); - inputs = List.copyOf(Objects.requireNonNull(inputs, "inputs")); - assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); - if (assetId <= 0 || assetUuid.isBlank() || assetName.isBlank() || relativeRoot.isBlank() || outputFormat.isBlank() || outputCodec.isBlank()) { - throw new IllegalArgumentException("planned asset fields must be valid"); - } - } -} diff --git a/prometeu-packer/src/main/java/p/packer/building/PackerPlannedInput.java b/prometeu-packer/src/main/java/p/packer/building/PackerPlannedInput.java deleted file mode 100644 index 70b6dd6c..00000000 --- a/prometeu-packer/src/main/java/p/packer/building/PackerPlannedInput.java +++ /dev/null @@ -1,21 +0,0 @@ -package p.packer.building; - -import java.nio.file.Path; -import java.util.Objects; - -public record PackerPlannedInput( - String role, - String relativePath, - String contentHash, - Path absolutePath) { - - public PackerPlannedInput { - role = Objects.requireNonNull(role, "role").trim(); - relativePath = Objects.requireNonNull(relativePath, "relativePath").trim(); - contentHash = Objects.requireNonNull(contentHash, "contentHash").trim(); - absolutePath = Objects.requireNonNull(absolutePath, "absolutePath").toAbsolutePath().normalize(); - if (role.isBlank() || relativePath.isBlank() || contentHash.isBlank()) { - throw new IllegalArgumentException("planned input fields must not be blank"); - } - } -} diff --git a/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDetailsService.java b/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDetailsService.java deleted file mode 100644 index cebbcf91..00000000 --- a/prometeu-packer/src/main/java/p/packer/declarations/PackerAssetDetailsService.java +++ /dev/null @@ -1,185 +0,0 @@ -package p.packer.declarations; - -import p.packer.api.PackerOperationStatus; -import p.packer.api.PackerProjectContext; -import p.packer.api.assets.PackerAssetDetails; -import p.packer.api.assets.PackerAssetIdentity; -import p.packer.api.assets.PackerAssetState; -import p.packer.api.assets.PackerAssetSummary; -import p.packer.api.assets.PackerBuildParticipation; -import p.packer.api.diagnostics.PackerDiagnostic; -import p.packer.api.diagnostics.PackerDiagnosticCategory; -import p.packer.api.diagnostics.PackerDiagnosticSeverity; -import p.packer.api.workspace.GetAssetDetailsRequest; -import p.packer.api.workspace.GetAssetDetailsResult; -import p.packer.foundation.PackerRegistryEntry; -import p.packer.foundation.PackerRegistryLookup; -import p.packer.foundation.PackerRegistryState; -import p.packer.foundation.PackerWorkspaceFoundation; -import p.packer.foundation.PackerWorkspacePaths; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; - -public final class PackerAssetDetailsService { - private final PackerWorkspaceFoundation workspaceFoundation; - private final PackerAssetDeclarationParser parser; - - public PackerAssetDetailsService() { - this(new PackerWorkspaceFoundation(), new PackerAssetDeclarationParser()); - } - - public PackerAssetDetailsService( - PackerWorkspaceFoundation workspaceFoundation, - PackerAssetDeclarationParser parser) { - this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation"); - this.parser = Objects.requireNonNull(parser, "parser"); - } - - public GetAssetDetailsResult getAssetDetails(GetAssetDetailsRequest request) { - final PackerProjectContext project = Objects.requireNonNull(request, "request").project(); - final PackerRegistryState registry = workspaceFoundation.loadRegistry(project); - final ResolvedAssetReference resolved = resolveReference(project, registry, request.assetReference()); - final Path manifestPath = resolved.assetRoot().resolve("asset.json"); - final List diagnostics = new ArrayList<>(resolved.diagnostics()); - - if (!Files.isRegularFile(manifestPath)) { - diagnostics.add(new PackerDiagnostic( - PackerDiagnosticSeverity.ERROR, - PackerDiagnosticCategory.STRUCTURAL, - "asset.json was not found for the requested asset root.", - manifestPath, - true)); - return failureResult(resolved, diagnostics); - } - - final PackerAssetDeclarationParseResult parsed = parser.parse(manifestPath); - diagnostics.addAll(parsed.diagnostics()); - if (!parsed.valid()) { - return failureResult(resolved, diagnostics); - } - - final PackerAssetDeclaration declaration = parsed.declaration(); - final PackerAssetState state = resolved.registryEntry().isPresent() - ? PackerAssetState.REGISTERED - : PackerAssetState.UNREGISTERED; - final PackerAssetSummary summary = new PackerAssetSummary( - new PackerAssetIdentity( - resolved.registryEntry().map(PackerRegistryEntry::assetId).orElse(null), - resolved.registryEntry().map(PackerRegistryEntry::assetUuid).orElse(null), - declaration.name(), - resolved.assetRoot()), - state, - resolved.registryEntry().map(entry -> entry.includedInBuild() - ? PackerBuildParticipation.INCLUDED - : PackerBuildParticipation.EXCLUDED).orElse(PackerBuildParticipation.EXCLUDED), - declaration.type(), - declaration.preloadEnabled(), - !diagnostics.isEmpty()); - final PackerAssetDetails details = new PackerAssetDetails( - summary, - declaration.outputFormat(), - declaration.outputCodec(), - resolveInputs(resolved.assetRoot(), declaration.inputsByRole()), - diagnostics); - return new GetAssetDetailsResult( - diagnostics.isEmpty() ? PackerOperationStatus.SUCCESS : PackerOperationStatus.PARTIAL, - "Asset details resolved from packer declaration state.", - details, - diagnostics); - } - - private GetAssetDetailsResult failureResult(ResolvedAssetReference resolved, List diagnostics) { - final PackerAssetState state = resolved.registryEntry().isPresent() - ? PackerAssetState.REGISTERED - : PackerAssetState.UNREGISTERED; - final PackerAssetSummary summary = new PackerAssetSummary( - new PackerAssetIdentity( - resolved.registryEntry().map(PackerRegistryEntry::assetId).orElse(null), - resolved.registryEntry().map(PackerRegistryEntry::assetUuid).orElse(null), - resolved.assetRoot().getFileName().toString(), - resolved.assetRoot()), - state, - resolved.registryEntry().map(entry -> entry.includedInBuild() - ? PackerBuildParticipation.INCLUDED - : PackerBuildParticipation.EXCLUDED).orElse(PackerBuildParticipation.EXCLUDED), - "unknown", - false, - true); - final PackerAssetDetails details = new PackerAssetDetails(summary, "unknown", "unknown", Map.of(), diagnostics); - return new GetAssetDetailsResult( - PackerOperationStatus.FAILED, - "Asset declaration is invalid or unreadable.", - details, - diagnostics); - } - - private Map> resolveInputs(Path assetRoot, Map> inputsByRole) { - final Map> resolved = new LinkedHashMap<>(); - inputsByRole.forEach((role, inputs) -> resolved.put( - role, - inputs.stream().map(input -> assetRoot.resolve(input).toAbsolutePath().normalize()).toList())); - return Map.copyOf(resolved); - } - - private ResolvedAssetReference resolveReference(PackerProjectContext project, PackerRegistryState registry, String reference) { - final PackerRegistryLookup lookup = workspaceFoundation.lookup(); - final Optional byId = parseAssetId(reference).flatMap(assetId -> lookup.findByAssetId(registry, assetId)); - if (byId.isPresent()) { - return new ResolvedAssetReference( - PackerWorkspacePaths.assetRoot(project, byId.get().root()), - byId, - List.of()); - } - - final Optional byUuid = lookup.findByAssetUuid(registry, reference); - if (byUuid.isPresent()) { - return new ResolvedAssetReference( - PackerWorkspacePaths.assetRoot(project, byUuid.get().root()), - byUuid, - List.of()); - } - - final Path candidateRoot = PackerWorkspacePaths.assetRoot(project, reference); - if (Files.isDirectory(candidateRoot) || Files.isRegularFile(candidateRoot.resolve("asset.json"))) { - return new ResolvedAssetReference(candidateRoot, lookup.findByRoot(project, registry, candidateRoot), List.of()); - } - - final Path missingRoot = candidateRoot; - return new ResolvedAssetReference( - missingRoot, - Optional.empty(), - List.of(new PackerDiagnostic( - PackerDiagnosticSeverity.ERROR, - PackerDiagnosticCategory.STRUCTURAL, - "Requested asset reference could not be resolved.", - missingRoot, - true))); - } - - private Optional parseAssetId(String reference) { - try { - return Optional.of(Integer.parseInt(reference.trim())); - } catch (NumberFormatException ignored) { - return Optional.empty(); - } - } - - private record ResolvedAssetReference( - Path assetRoot, - Optional registryEntry, - List diagnostics) { - - private ResolvedAssetReference { - assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); - registryEntry = Objects.requireNonNull(registryEntry, "registryEntry"); - diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); - } - } -} diff --git a/prometeu-packer/src/main/java/p/packer/doctor/FileSystemPackerDoctorService.java b/prometeu-packer/src/main/java/p/packer/doctor/FileSystemPackerDoctorService.java deleted file mode 100644 index f4966508..00000000 --- a/prometeu-packer/src/main/java/p/packer/doctor/FileSystemPackerDoctorService.java +++ /dev/null @@ -1,162 +0,0 @@ -package p.packer.doctor; - -import p.packer.api.PackerOperationClass; -import p.packer.api.PackerOperationStatus; -import p.packer.api.PackerProjectContext; -import p.packer.api.assets.PackerAssetState; -import p.packer.api.diagnostics.PackerDiagnostic; -import p.packer.api.diagnostics.PackerDiagnosticCategory; -import p.packer.api.diagnostics.PackerDiagnosticSeverity; -import p.packer.api.doctor.PackerDoctorMode; -import p.packer.api.doctor.PackerDoctorRequest; -import p.packer.api.doctor.PackerDoctorResult; -import p.packer.api.doctor.PackerDoctorService; -import p.packer.api.events.PackerEventKind; -import p.packer.api.events.PackerEventSink; -import p.packer.api.events.PackerProgress; -import p.packer.api.workspace.GetAssetDetailsRequest; -import p.packer.api.workspace.ListAssetsRequest; -import p.packer.declarations.PackerAssetDetailsService; -import p.packer.events.PackerOperationEventEmitter; -import p.packer.workspace.FileSystemPackerWorkspaceService; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Objects; -import java.util.Set; - -public final class FileSystemPackerDoctorService implements PackerDoctorService { - private final FileSystemPackerWorkspaceService workspaceService; - private final PackerAssetDetailsService detailsService; - private final PackerEventSink eventSink; - - public FileSystemPackerDoctorService() { - this(new FileSystemPackerWorkspaceService(), new PackerAssetDetailsService(), PackerEventSink.noop()); - } - - public FileSystemPackerDoctorService( - FileSystemPackerWorkspaceService workspaceService, - PackerAssetDetailsService detailsService) { - this(workspaceService, detailsService, PackerEventSink.noop()); - } - - public FileSystemPackerDoctorService( - FileSystemPackerWorkspaceService workspaceService, - PackerAssetDetailsService detailsService, - PackerEventSink eventSink) { - this.workspaceService = Objects.requireNonNull(workspaceService, "workspaceService"); - this.detailsService = Objects.requireNonNull(detailsService, "detailsService"); - this.eventSink = Objects.requireNonNull(eventSink, "eventSink"); - } - - @Override - public PackerOperationClass operationClass() { - return PackerOperationClass.READ_ONLY; - } - - @Override - public PackerDoctorResult doctor(PackerDoctorRequest request) { - final PackerDoctorRequest doctorRequest = Objects.requireNonNull(request, "request"); - final PackerProjectContext project = doctorRequest.project(); - final PackerOperationEventEmitter events = new PackerOperationEventEmitter(project, eventSink); - final var snapshot = workspaceService.listAssets(new ListAssetsRequest(project)); - final List diagnostics = new ArrayList<>(); - final Set safeFixes = new LinkedHashSet<>(); - final Set seenDiagnostics = new LinkedHashSet<>(); - - for (PackerDiagnostic diagnostic : snapshot.diagnostics()) { - addDiagnostic(diagnostics, seenDiagnostics, diagnostic); - } - - final int totalAssets = snapshot.assets().size(); - int inspected = 0; - for (var asset : snapshot.assets()) { - if (!includeAsset(doctorRequest.mode(), asset.state())) { - continue; - } - inspected += 1; - final String assetReference = asset.identity().assetId() == null - ? relativeAssetRoot(project, asset.identity().assetRoot()) - : Integer.toString(asset.identity().assetId()); - final var details = detailsService.getAssetDetails(new GetAssetDetailsRequest(project, assetReference)).details(); - for (PackerDiagnostic diagnostic : details.diagnostics()) { - addDiagnostic(diagnostics, seenDiagnostics, diagnostic); - } - - if (doctorRequest.mode() == PackerDoctorMode.EXPANDED_WORKSPACE && asset.state() == PackerAssetState.UNREGISTERED) { - addDiagnostic(diagnostics, seenDiagnostics, new PackerDiagnostic( - PackerDiagnosticSeverity.WARNING, - PackerDiagnosticCategory.HYGIENE, - "Asset is present in assets/ but not registered, so it stays excluded from builds: " + relativeAssetRoot(project, asset.identity().assetRoot()), - asset.identity().assetRoot(), - false)); - if (doctorRequest.includeSafeFixes() && details.diagnostics().stream().noneMatch(PackerDiagnostic::blocking)) { - safeFixes.add("register_asset " + relativeAssetRoot(project, asset.identity().assetRoot())); - } - } - - details.inputsByRole().forEach((role, inputs) -> inputs.forEach(input -> { - if (Files.isRegularFile(input)) { - return; - } - final boolean registered = asset.state() == PackerAssetState.REGISTERED; - addDiagnostic(diagnostics, seenDiagnostics, new PackerDiagnostic( - registered ? PackerDiagnosticSeverity.ERROR : PackerDiagnosticSeverity.WARNING, - registered ? PackerDiagnosticCategory.STRUCTURAL : PackerDiagnosticCategory.HYGIENE, - "Declared input is missing for role '" + role + "': " + relativeEvidence(project, input), - input, - registered)); - })); - events.emit( - PackerEventKind.PROGRESS_UPDATED, - "Doctor inspected asset: " + asset.identity().assetName(), - new PackerProgress(totalAssets == 0 ? 1.0d : inspected / (double) totalAssets, false), - List.of(asset.identity().assetName())); - } - - final long blockingCount = diagnostics.stream().filter(PackerDiagnostic::blocking).count(); - final long warningCount = diagnostics.stream().filter(diagnostic -> diagnostic.severity() == PackerDiagnosticSeverity.WARNING).count(); - final PackerOperationStatus status = blockingCount > 0L ? PackerOperationStatus.FAILED - : diagnostics.isEmpty() ? PackerOperationStatus.SUCCESS : PackerOperationStatus.PARTIAL; - final String summary = blockingCount > 0L - ? "Doctor found " + blockingCount + " blocking diagnostics and " + warningCount + " warnings." - : diagnostics.isEmpty() - ? "Doctor found no diagnostics." - : "Doctor found " + diagnostics.size() + " diagnostics with no blockers."; - events.emit(PackerEventKind.DIAGNOSTICS_UPDATED, summary, List.of()); - return new PackerDoctorResult(status, summary, diagnostics, List.copyOf(safeFixes)); - } - - private boolean includeAsset(PackerDoctorMode mode, PackerAssetState state) { - if (mode == PackerDoctorMode.EXPANDED_WORKSPACE) { - return true; - } - return state == PackerAssetState.REGISTERED; - } - - private void addDiagnostic(List diagnostics, Set seenDiagnostics, PackerDiagnostic diagnostic) { - final String key = diagnostic.category() + "|" + diagnostic.severity() + "|" + diagnostic.message() + "|" + diagnostic.evidencePath() + "|" + diagnostic.blocking(); - if (seenDiagnostics.add(key)) { - diagnostics.add(diagnostic); - } - } - - private String relativeAssetRoot(PackerProjectContext project, Path assetRoot) { - return project.rootPath().resolve("assets").toAbsolutePath().normalize() - .relativize(assetRoot.toAbsolutePath().normalize()) - .toString() - .replace('\\', '/'); - } - - private String relativeEvidence(PackerProjectContext project, Path evidencePath) { - final Path normalizedEvidence = evidencePath.toAbsolutePath().normalize(); - final Path projectRoot = project.rootPath().toAbsolutePath().normalize(); - if (normalizedEvidence.startsWith(projectRoot)) { - return projectRoot.relativize(normalizedEvidence).toString().replace('\\', '/'); - } - return normalizedEvidence.toString(); - } -} diff --git a/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java b/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java deleted file mode 100644 index 8e4d37c0..00000000 --- a/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java +++ /dev/null @@ -1,395 +0,0 @@ -package p.packer.mutations; - -import p.packer.api.PackerOperationClass; -import p.packer.api.PackerOperationStatus; -import p.packer.api.PackerProjectContext; -import p.packer.api.assets.PackerAssetDetails; -import p.packer.api.assets.PackerAssetState; -import p.packer.api.diagnostics.PackerDiagnostic; -import p.packer.api.events.PackerEvent; -import p.packer.api.events.PackerEventKind; -import p.packer.api.events.PackerEventSink; -import p.packer.api.mutations.PackerMutationPreview; -import p.packer.api.mutations.PackerMutationRequest; -import p.packer.api.mutations.PackerMutationResult; -import p.packer.api.mutations.PackerMutationService; -import p.packer.api.mutations.PackerMutationType; -import p.packer.api.mutations.PackerProposedAction; -import p.packer.api.workspace.GetAssetDetailsRequest; -import p.packer.declarations.PackerAssetDetailsService; -import p.packer.foundation.PackerRegistryEntry; -import p.packer.foundation.PackerRegistryState; -import p.packer.foundation.PackerWorkspaceFoundation; -import p.packer.foundation.PackerWorkspacePaths; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import java.util.stream.Stream; - -public final class FileSystemPackerMutationService implements PackerMutationService { - private final PackerWorkspaceFoundation workspaceFoundation; - private final PackerAssetDetailsService detailsService; - private final PackerProjectWriteCoordinator writeCoordinator; - private final PackerEventSink eventSink; - - public FileSystemPackerMutationService() { - this(new PackerWorkspaceFoundation(), new PackerAssetDetailsService(), new PackerProjectWriteCoordinator(), PackerEventSink.noop()); - } - - public FileSystemPackerMutationService( - PackerWorkspaceFoundation workspaceFoundation, - PackerAssetDetailsService detailsService, - PackerProjectWriteCoordinator writeCoordinator, - PackerEventSink eventSink) { - this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation"); - this.detailsService = Objects.requireNonNull(detailsService, "detailsService"); - this.writeCoordinator = Objects.requireNonNull(writeCoordinator, "writeCoordinator"); - this.eventSink = Objects.requireNonNull(eventSink, "eventSink"); - } - - @Override - public PackerOperationClass operationClass() { - return PackerOperationClass.WORKSPACE_MUTATION; - } - - @Override - public PackerMutationPreview preview(PackerMutationRequest request) { - final ResolvedMutationContext context = resolveContext(Objects.requireNonNull(request, "request")); - final PackerMutationPreview preview = buildPreview(context); - emit(context.project(), preview.operationId(), 0L, PackerEventKind.PREVIEW_READY, preview.summary(), affectedAssets(preview)); - return preview; - } - - @Override - public PackerMutationResult apply(PackerMutationPreview preview) { - Objects.requireNonNull(preview, "preview"); - final PackerProjectContext project = preview.request().project(); - try { - if (!preview.canApply()) { - throw new PackerMutationException("Cannot apply mutation preview with blockers"); - } - final PackerMutationResult result = writeCoordinator.withWriteLock(project, () -> applyLocked(preview)); - emit(project, result.operationId(), 1L, PackerEventKind.ASSET_CHANGED, "Asset state changed.", affectedAssets(preview)); - emit(project, result.operationId(), 2L, PackerEventKind.ACTION_APPLIED, result.summary(), affectedAssets(preview)); - return result; - } catch (RuntimeException exception) { - emit(project, preview.operationId(), 2L, PackerEventKind.ACTION_FAILED, rootCauseMessage(exception), affectedAssets(preview)); - throw exception; - } - } - - private PackerMutationResult applyLocked(PackerMutationPreview preview) { - final PackerProjectContext project = preview.request().project(); - final PackerRegistryState registry = workspaceFoundation.loadRegistry(project); - final ResolvedMutationContext context = resolveContext(preview.request()); - final Path assetRoot = context.assetDetails().summary().identity().assetRoot(); - - return switch (preview.request().type()) { - case REGISTER_ASSET -> applyRegister(project, registry, assetRoot, preview); - case INCLUDE_ASSET_IN_BUILD -> applyBuildParticipationChange(project, registry, assetRoot, preview, true); - case EXCLUDE_ASSET_FROM_BUILD -> applyBuildParticipationChange(project, registry, assetRoot, preview, false); - case REMOVE_ASSET -> applyRemove(project, registry, assetRoot, preview); - case RELOCATE_ASSET -> applyRelocate(project, registry, assetRoot, preview); - }; - } - - private PackerMutationResult applyRegister( - PackerProjectContext project, - PackerRegistryState registry, - Path assetRoot, - PackerMutationPreview preview) { - final String relativeRoot = PackerWorkspacePaths.relativeAssetRoot(project, assetRoot); - final Optional existing = workspaceFoundation.lookup().findByRoot(project, registry, assetRoot); - if (existing.isPresent()) { - return new PackerMutationResult( - PackerOperationStatus.SUCCESS, - "Asset is already registered.", - preview.operationId(), - List.of(), - List.of()); - } - final PackerRegistryEntry entry = workspaceFoundation.allocateIdentity(project, registry, assetRoot); - final PackerRegistryState updated = workspaceFoundation.appendAllocatedEntry(registry, entry); - workspaceFoundation.saveRegistry(project, updated); - return new PackerMutationResult( - PackerOperationStatus.SUCCESS, - "Asset registered: " + relativeRoot, - preview.operationId(), - preview.proposedActions(), - List.of()); - } - - private PackerMutationResult applyBuildParticipationChange( - PackerProjectContext project, - PackerRegistryState registry, - Path assetRoot, - PackerMutationPreview preview, - boolean includedInBuild) { - final PackerRegistryState updated = registry.withAssets( - registry.assets().stream() - .map(entry -> PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot) - ? new PackerRegistryEntry(entry.assetId(), entry.assetUuid(), entry.root(), includedInBuild) - : entry) - .toList(), - registry.nextAssetId()); - workspaceFoundation.saveRegistry(project, updated); - return new PackerMutationResult( - PackerOperationStatus.SUCCESS, - includedInBuild ? "Asset included in builds." : "Asset excluded from builds.", - preview.operationId(), - preview.proposedActions(), - List.of()); - } - - private PackerMutationResult applyRemove( - 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); - deleteRecursively(assetRoot); - return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset removed from workspace.", preview.operationId(), preview.proposedActions(), List.of()); - } - - private PackerMutationResult applyRelocate( - PackerProjectContext project, - PackerRegistryState registry, - Path assetRoot, - PackerMutationPreview preview) { - final Path targetRoot = requireTarget(preview); - final PackerRegistryState updated = registry.withAssets( - registry.assets().stream() - .map(entry -> PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot) - ? new PackerRegistryEntry( - entry.assetId(), - entry.assetUuid(), - PackerWorkspacePaths.relativeAssetRoot(project, targetRoot), - entry.includedInBuild()) - : entry) - .toList(), - registry.nextAssetId()); - workspaceFoundation.saveRegistry(project, updated); - moveAssetRoot(assetRoot, targetRoot); - return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset root relocated.", preview.operationId(), preview.proposedActions(), List.of()); - } - - private PackerMutationPreview buildPreview(ResolvedMutationContext context) { - final PackerMutationRequest request = context.request(); - final PackerAssetDetails assetDetails = context.assetDetails(); - final Path assetRoot = assetDetails.summary().identity().assetRoot(); - final String relativeRoot = PackerWorkspacePaths.relativeAssetRoot(context.project(), assetRoot); - final boolean managed = assetDetails.summary().identity().assetId() != null; - final List actions = new ArrayList<>(); - final List blockers = new ArrayList<>(context.initialBlockers()); - final List warnings = new ArrayList<>(); - final List safeFixes = new ArrayList<>(); - Path targetAssetRoot = null; - - switch (request.type()) { - case REGISTER_ASSET -> { - if (managed) { - blockers.add("Asset is already registered."); - } - if (assetDetails.diagnostics().stream().anyMatch(PackerDiagnostic::blocking)) { - blockers.add("Asset declaration must be valid before registration."); - } - if (!blockers.isEmpty()) { - break; - } - actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "ADD", relativeRoot)); - if (assetDetails.summary().hasDiagnostics()) { - warnings.add("Asset currently reports diagnostics and will still be registered."); - } - } - case INCLUDE_ASSET_IN_BUILD -> { - if (!managed) { - blockers.add("Only registered assets can be included in builds."); - } else if (assetDetails.summary().buildParticipation() == p.packer.api.assets.PackerBuildParticipation.INCLUDED) { - blockers.add("Asset is already included in builds."); - } else { - actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "UPDATE", relativeRoot)); - warnings.add("The asset will remain registered and return to the build set."); - } - } - case EXCLUDE_ASSET_FROM_BUILD -> { - if (!managed) { - blockers.add("Only registered assets can be excluded from builds."); - } else if (assetDetails.summary().buildParticipation() == p.packer.api.assets.PackerBuildParticipation.EXCLUDED) { - blockers.add("Asset is already excluded from builds."); - } else { - actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "UPDATE", relativeRoot)); - warnings.add("The asset will remain registered but leave the build set."); - } - } - case REMOVE_ASSET -> { - if (managed) { - actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "REMOVE", relativeRoot)); - } - actions.add(new PackerProposedAction(PackerOperationClass.WORKSPACE_MUTATION, "DELETE", relativeRoot)); - warnings.add("Physical files inside the asset root will be deleted."); - } - case RELOCATE_ASSET -> { - targetAssetRoot = request.targetRoot() != null - ? request.targetRoot() - : relocationTarget(context.project(), assetRoot, assetDetails.summary().identity().assetName()); - final String targetRelativeRoot = PackerWorkspacePaths.relativeAssetRoot(context.project(), targetAssetRoot); - if (assetRoot.equals(targetAssetRoot)) { - blockers.add("Asset is already at the planned relocation target."); - } else { - if (managed) { - actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "UPDATE", relativeRoot + " -> " + targetRelativeRoot)); - } - actions.add(new PackerProposedAction(PackerOperationClass.WORKSPACE_MUTATION, "MOVE", relativeRoot + " -> " + targetRelativeRoot)); - warnings.add("Relocation preserves asset identity, but it changes the root path seen by the workspace."); - } - } - } - - return new PackerMutationPreview( - blockers.isEmpty() ? PackerOperationStatus.SUCCESS : PackerOperationStatus.PARTIAL, - "Mutation preview ready for " + request.type().name().toLowerCase(), - UUID.randomUUID().toString(), - request, - actions, - assetDetails.diagnostics(), - blockers, - warnings, - safeFixes, - request.type() == PackerMutationType.REMOVE_ASSET || request.type() == PackerMutationType.RELOCATE_ASSET, - targetAssetRoot); - } - - private ResolvedMutationContext resolveContext(PackerMutationRequest request) { - final PackerAssetDetails assetDetails = detailsService.getAssetDetails( - new GetAssetDetailsRequest(request.project(), request.assetReference())).details(); - final List initialBlockers = assetDetails.diagnostics().stream() - .filter(PackerDiagnostic::blocking) - .map(PackerDiagnostic::message) - .filter(message -> request.type() != PackerMutationType.INCLUDE_ASSET_IN_BUILD - && request.type() != PackerMutationType.EXCLUDE_ASSET_FROM_BUILD - && request.type() != PackerMutationType.REMOVE_ASSET - && request.type() != PackerMutationType.RELOCATE_ASSET) - .toList(); - return new ResolvedMutationContext(request.project(), request, assetDetails, initialBlockers); - } - - private Path relocationTarget(PackerProjectContext project, Path assetRoot, String assetName) { - final Path siblingParent = assetRoot.getParent() == null ? PackerWorkspacePaths.assetsRoot(project) : assetRoot.getParent(); - return nextAvailablePath(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 String sanitizeSegment(String value) { - final String sanitized = value == null - ? "asset" - : value.trim() - .replaceAll("[^A-Za-z0-9._-]+", "-") - .replaceAll("-{2,}", "-") - .replaceAll("^[.-]+|[.-]+$", ""); - return sanitized.isBlank() ? "asset" : sanitized; - } - - private void moveAssetRoot(Path sourceRoot, Path targetRoot) { - if (sourceRoot.equals(targetRoot)) { - return; - } - try { - Files.createDirectories(targetRoot.getParent()); - Files.move(sourceRoot, targetRoot); - } catch (IOException exception) { - throw new UncheckedIOException(exception); - } - } - - private void deleteRecursively(Path root) { - if (!Files.exists(root)) { - return; - } - try (Stream stream = Files.walk(root)) { - stream.sorted(Comparator.reverseOrder()).forEach(path -> { - try { - Files.deleteIfExists(path); - } catch (IOException exception) { - throw new UncheckedIOException(exception); - } - }); - } catch (IOException exception) { - throw new UncheckedIOException(exception); - } - } - - private Path requireTarget(PackerMutationPreview preview) { - if (preview.targetAssetRoot() == null) { - throw new PackerMutationException("Mutation preview does not define a target asset root"); - } - final Path targetRoot = preview.targetAssetRoot(); - final Path projectRoot = preview.request().project().rootPath().toAbsolutePath().normalize(); - if (!targetRoot.toAbsolutePath().normalize().startsWith(projectRoot.resolve("assets").toAbsolutePath().normalize())) { - throw new PackerMutationException("Mutation target root is outside the trusted assets boundary"); - } - return targetRoot; - } - - private void emit( - PackerProjectContext project, - String operationId, - long sequence, - PackerEventKind kind, - String summary, - List affectedAssets) { - eventSink.publish(new PackerEvent( - project.projectId(), - operationId, - sequence, - kind, - Instant.now(), - summary, - null, - affectedAssets)); - } - - private List affectedAssets(PackerMutationPreview preview) { - return List.of(preview.request().assetReference()); - } - - private String rootCauseMessage(Throwable throwable) { - Throwable current = throwable; - while (current.getCause() != null) { - current = current.getCause(); - } - return current.getMessage() == null || current.getMessage().isBlank() - ? current.getClass().getSimpleName() - : current.getMessage(); - } - - private record ResolvedMutationContext( - PackerProjectContext project, - PackerMutationRequest request, - PackerAssetDetails assetDetails, - List initialBlockers) { - } -} diff --git a/prometeu-packer/src/main/java/p/packer/mutations/PackerMutationException.java b/prometeu-packer/src/main/java/p/packer/mutations/PackerMutationException.java deleted file mode 100644 index 778d12a6..00000000 --- a/prometeu-packer/src/main/java/p/packer/mutations/PackerMutationException.java +++ /dev/null @@ -1,11 +0,0 @@ -package p.packer.mutations; - -public final class PackerMutationException extends RuntimeException { - public PackerMutationException(String message) { - super(message); - } - - public PackerMutationException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/prometeu-packer/src/main/java/p/packer/mutations/PackerProjectWriteCoordinator.java b/prometeu-packer/src/main/java/p/packer/mutations/PackerProjectWriteCoordinator.java deleted file mode 100644 index f063ab40..00000000 --- a/prometeu-packer/src/main/java/p/packer/mutations/PackerProjectWriteCoordinator.java +++ /dev/null @@ -1,25 +0,0 @@ -package p.packer.mutations; - -import p.packer.api.PackerProjectContext; - -import java.nio.file.Path; -import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.Supplier; - -public final class PackerProjectWriteCoordinator { - private final ConcurrentMap locks = new ConcurrentHashMap<>(); - - public T withWriteLock(PackerProjectContext project, Supplier work) { - final Path key = Objects.requireNonNull(project, "project").rootPath().toAbsolutePath().normalize(); - final ReentrantLock lock = locks.computeIfAbsent(key, ignored -> new ReentrantLock()); - lock.lock(); - try { - return Objects.requireNonNull(work, "work").get(); - } finally { - lock.unlock(); - } - } -} diff --git a/prometeu-packer/src/main/java/p/packer/workspace/FileSystemPackerWorkspaceService.java b/prometeu-packer/src/main/java/p/packer/workspace/FileSystemPackerWorkspaceService.java deleted file mode 100644 index eb3a7f30..00000000 --- a/prometeu-packer/src/main/java/p/packer/workspace/FileSystemPackerWorkspaceService.java +++ /dev/null @@ -1,182 +0,0 @@ -package p.packer.workspace; - -import p.packer.api.PackerOperationClass; -import p.packer.api.PackerOperationStatus; -import p.packer.api.PackerProjectContext; -import p.packer.api.assets.PackerAssetIdentity; -import p.packer.api.assets.PackerAssetState; -import p.packer.api.assets.PackerAssetSummary; -import p.packer.api.assets.PackerBuildParticipation; -import p.packer.api.diagnostics.PackerDiagnostic; -import p.packer.api.diagnostics.PackerDiagnosticCategory; -import p.packer.api.diagnostics.PackerDiagnosticSeverity; -import p.packer.api.events.PackerEventKind; -import p.packer.api.events.PackerEventSink; -import p.packer.api.events.PackerProgress; -import p.packer.api.workspace.GetAssetDetailsRequest; -import p.packer.api.workspace.GetAssetDetailsResult; -import p.packer.api.workspace.InitWorkspaceRequest; -import p.packer.api.workspace.InitWorkspaceResult; -import p.packer.api.workspace.ListAssetsRequest; -import p.packer.api.workspace.ListAssetsResult; -import p.packer.api.workspace.PackerWorkspaceService; -import p.packer.declarations.PackerAssetDeclarationParseResult; -import p.packer.declarations.PackerAssetDeclarationParser; -import p.packer.declarations.PackerAssetDetailsService; -import p.packer.foundation.PackerRegistryEntry; -import p.packer.foundation.PackerRegistryState; -import p.packer.foundation.PackerWorkspaceFoundation; -import p.packer.foundation.PackerWorkspacePaths; -import p.packer.events.PackerOperationEventEmitter; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; -import java.util.stream.Stream; - -public final class FileSystemPackerWorkspaceService implements PackerWorkspaceService { - private final PackerWorkspaceFoundation workspaceFoundation; - private final PackerAssetDeclarationParser parser; - private final PackerAssetDetailsService detailsService; - private final PackerEventSink eventSink; - - public FileSystemPackerWorkspaceService() { - this(new PackerWorkspaceFoundation(), new PackerAssetDeclarationParser(), PackerEventSink.noop()); - } - - public FileSystemPackerWorkspaceService( - PackerWorkspaceFoundation workspaceFoundation, - PackerAssetDeclarationParser parser) { - this(workspaceFoundation, parser, PackerEventSink.noop()); - } - - public FileSystemPackerWorkspaceService( - PackerWorkspaceFoundation workspaceFoundation, - PackerAssetDeclarationParser parser, - PackerEventSink eventSink) { - this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation"); - this.parser = Objects.requireNonNull(parser, "parser"); - this.detailsService = new PackerAssetDetailsService(workspaceFoundation, parser); - this.eventSink = Objects.requireNonNull(eventSink, "eventSink"); - } - - @Override - public PackerOperationClass operationClass() { - return PackerOperationClass.READ_ONLY; - } - - @Override - public InitWorkspaceResult initWorkspace(InitWorkspaceRequest request) { - return workspaceFoundation.initWorkspace(request); - } - - @Override - public ListAssetsResult listAssets(ListAssetsRequest request) { - final PackerProjectContext project = Objects.requireNonNull(request, "request").project(); - final PackerOperationEventEmitter events = new PackerOperationEventEmitter(project, eventSink); - final Path assetsRoot = PackerWorkspacePaths.assetsRoot(project); - final PackerRegistryState registry = workspaceFoundation.loadRegistry(project); - final Map registryByRoot = new HashMap<>(); - for (PackerRegistryEntry entry : registry.assets()) { - registryByRoot.put(PackerWorkspacePaths.assetRoot(project, entry.root()), entry); - } - - final List assets = new ArrayList<>(); - final List diagnostics = new ArrayList<>(); - final Set discoveredRoots = new HashSet<>(); - - if (Files.isDirectory(assetsRoot)) { - try (Stream paths = Files.find(assetsRoot, Integer.MAX_VALUE, (path, attrs) -> - attrs.isRegularFile() && path.getFileName().toString().equals("asset.json"))) { - final List manifests = paths.toList(); - final int total = manifests.size(); - for (int index = 0; index < manifests.size(); index += 1) { - final Path assetManifestPath = manifests.get(index); - final Path assetRoot = assetManifestPath.getParent().toAbsolutePath().normalize(); - discoveredRoots.add(assetRoot); - final PackerRegistryEntry registryEntry = registryByRoot.get(assetRoot); - final PackerAssetDeclarationParseResult parsed = parser.parse(assetManifestPath); - diagnostics.addAll(parsed.diagnostics()); - final PackerAssetSummary summary = buildSummary(assetRoot, registryEntry, parsed); - assets.add(summary); - events.emit( - PackerEventKind.ASSET_DISCOVERED, - "Discovered asset: " + summary.identity().assetName(), - new PackerProgress(total == 0 ? 1.0d : (index + 1) / (double) total, false), - List.of(summary.identity().assetName())); - } - } catch (IOException exception) { - diagnostics.add(new PackerDiagnostic( - PackerDiagnosticSeverity.ERROR, - PackerDiagnosticCategory.STRUCTURAL, - "Unable to scan assets workspace: " + exception.getMessage(), - assetsRoot, - true)); - } - } - - for (PackerRegistryEntry entry : registry.assets()) { - final Path registeredRoot = PackerWorkspacePaths.assetRoot(project, entry.root()); - if (!discoveredRoots.contains(registeredRoot)) { - diagnostics.add(new PackerDiagnostic( - PackerDiagnosticSeverity.ERROR, - PackerDiagnosticCategory.STRUCTURAL, - "Registered asset root is missing asset.json: " + entry.root(), - registeredRoot.resolve("asset.json"), - true)); - } - } - - assets.sort(Comparator - .comparing((PackerAssetSummary asset) -> asset.identity().assetRoot().toString(), String.CASE_INSENSITIVE_ORDER) - .thenComparing(summary -> summary.identity().assetName(), String.CASE_INSENSITIVE_ORDER)); - final PackerOperationStatus status = diagnostics.stream().anyMatch(PackerDiagnostic::blocking) - ? PackerOperationStatus.PARTIAL - : PackerOperationStatus.SUCCESS; - if (!diagnostics.isEmpty()) { - events.emit(PackerEventKind.DIAGNOSTICS_UPDATED, "Asset scan diagnostics updated.", List.of()); - } - return new ListAssetsResult( - status, - "Packer asset snapshot ready.", - assets, - diagnostics); - } - - @Override - public GetAssetDetailsResult getAssetDetails(GetAssetDetailsRequest request) { - return detailsService.getAssetDetails(request); - } - - private PackerAssetSummary buildSummary( - Path assetRoot, - PackerRegistryEntry registryEntry, - PackerAssetDeclarationParseResult parsed) { - final String assetName = parsed.declaration() != null - ? parsed.declaration().name() - : assetRoot.getFileName().toString(); - final String assetFamily = parsed.declaration() != null - ? parsed.declaration().type() - : "unknown"; - final boolean preload = parsed.declaration() != null && parsed.declaration().preloadEnabled(); - final boolean hasDiagnostics = !parsed.diagnostics().isEmpty(); - final PackerAssetState state = registryEntry == null - ? PackerAssetState.UNREGISTERED - : PackerAssetState.REGISTERED; - final PackerBuildParticipation buildParticipation = state == PackerAssetState.REGISTERED - ? (registryEntry.includedInBuild() ? PackerBuildParticipation.INCLUDED : PackerBuildParticipation.EXCLUDED) - : PackerBuildParticipation.EXCLUDED; - return new PackerAssetSummary( - new PackerAssetIdentity( - registryEntry == null ? null : registryEntry.assetId(), - registryEntry == null ? null : registryEntry.assetUuid(), - assetName, - assetRoot), - state, - buildParticipation, - assetFamily, - preload, - hasDiagnostics); - } -} diff --git a/prometeu-packer/src/test/java/p/packer/building/FileSystemPackerBuildServiceTest.java b/prometeu-packer/src/test/java/p/packer/building/FileSystemPackerBuildServiceTest.java deleted file mode 100644 index 40d36b50..00000000 --- a/prometeu-packer/src/test/java/p/packer/building/FileSystemPackerBuildServiceTest.java +++ /dev/null @@ -1,192 +0,0 @@ -package p.packer.building; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import p.packer.api.PackerOperationStatus; -import p.packer.api.PackerProjectContext; -import p.packer.api.building.PackerBuildRequest; -import p.packer.api.events.PackerEvent; -import p.packer.api.events.PackerEventKind; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CopyOnWriteArrayList; - -import static org.junit.jupiter.api.Assertions.*; - -final class FileSystemPackerBuildServiceTest { - private static final ObjectMapper MAPPER = new ObjectMapper(); - - @TempDir - Path tempDir; - - @Test - void buildEmitsArchiveAndCompanionArtifactsDeterministically() throws Exception { - final Path projectRoot = createProject(tempDir.resolve("main")); - final FileSystemPackerBuildService service = new FileSystemPackerBuildService(); - - final var first = service.build(new PackerBuildRequest(new PackerProjectContext("main", projectRoot), false)); - final byte[] firstBytes = Files.readAllBytes(first.assetsArchive()); - final String firstAssetTable = Files.readString(first.companionArtifacts().get("build/asset_table.json")); - final String firstPreload = Files.readString(first.companionArtifacts().get("build/preload.json")); - - final var second = service.build(new PackerBuildRequest(new PackerProjectContext("main", projectRoot), false)); - final byte[] secondBytes = Files.readAllBytes(second.assetsArchive()); - - assertEquals(PackerOperationStatus.SUCCESS, first.status()); - assertArrayEquals(firstBytes, secondBytes); - assertEquals(firstAssetTable, Files.readString(second.companionArtifacts().get("build/asset_table.json"))); - assertEquals(firstPreload, Files.readString(second.companionArtifacts().get("build/preload.json"))); - } - - @Test - void companionArtifactsMirrorHeaderSections() throws Exception { - final Path projectRoot = createProject(tempDir.resolve("mirror")); - final FileSystemPackerBuildService service = new FileSystemPackerBuildService(); - - final var result = service.build(new PackerBuildRequest(new PackerProjectContext("mirror", projectRoot), false)); - final ParsedArchive archive = parseArchive(result.assetsArchive()); - final String assetTableJson = Files.readString(result.companionArtifacts().get("build/asset_table.json")); - final String preloadJson = Files.readString(result.companionArtifacts().get("build/preload.json")); - final Map metadata = MAPPER.readValue(Files.readString(result.companionArtifacts().get("build/asset_table_metadata.json")), Map.class); - - assertEquals(archive.header().get("asset_table"), MAPPER.readValue(assetTableJson, List.class)); - assertEquals(archive.header().get("preload"), MAPPER.readValue(preloadJson, List.class)); - assertEquals(2, metadata.get("asset_count")); - assertEquals(archive.payloadOffset(), metadata.get("payload_offset")); - } - - @Test - void buildFailsCleanlyWhenPlannerIsBlocked() throws Exception { - final Path projectRoot = createProject(tempDir.resolve("broken")); - Files.delete(projectRoot.resolve("assets/ui/atlas/sprites/confirm.png")); - final FileSystemPackerBuildService service = new FileSystemPackerBuildService(); - - final var result = service.build(new PackerBuildRequest(new PackerProjectContext("broken", projectRoot), false)); - - assertEquals(PackerOperationStatus.FAILED, result.status()); - assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("Build input is missing"))); - assertTrue(result.companionArtifacts().isEmpty()); - } - - @Test - void emitsBuildLifecycleAndCacheEvents() throws Exception { - final Path projectRoot = createProject(tempDir.resolve("events")); - final List events = new CopyOnWriteArrayList<>(); - final FileSystemPackerBuildService service = new FileSystemPackerBuildService(new PackerBuildPlanner(), events::add); - - service.build(new PackerBuildRequest(new PackerProjectContext("events", projectRoot), false)); - service.build(new PackerBuildRequest(new PackerProjectContext("events", projectRoot), false)); - - assertEquals(PackerEventKind.BUILD_STARTED, events.getFirst().kind()); - assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.CACHE_MISS)); - assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.CACHE_HIT)); - assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.BUILD_FINISHED)); - } - - @Test - void buildFailsOnUnsupportedMetadataSchema() throws Exception { - final Path projectRoot = createProject(tempDir.resolve("unsupported-metadata")); - Files.createDirectories(projectRoot.resolve("build")); - Files.writeString(projectRoot.resolve("build/asset_table_metadata.json"), """ - { - "schema_version": 99, - "cache_key": "legacy" - } - """); - final FileSystemPackerBuildService service = new FileSystemPackerBuildService(); - - final var result = service.build(new PackerBuildRequest(new PackerProjectContext("unsupported-metadata", projectRoot), false)); - - assertEquals(PackerOperationStatus.FAILED, result.status()); - assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("Unsupported build metadata schema_version"))); - } - - @Test - void incrementalBuildFailsOnUnsupportedExistingArchivePrelude() throws Exception { - final Path projectRoot = createProject(tempDir.resolve("unsupported-archive")); - Files.createDirectories(projectRoot.resolve("build")); - Files.write(projectRoot.resolve("build/assets.pa"), "BAD!".getBytes(StandardCharsets.UTF_8)); - final FileSystemPackerBuildService service = new FileSystemPackerBuildService(); - - final var result = service.build(new PackerBuildRequest(new PackerProjectContext("unsupported-archive", projectRoot), true)); - - assertEquals(PackerOperationStatus.FAILED, result.status()); - assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("prelude is truncated"))); - } - - private ParsedArchive parseArchive(Path archivePath) throws Exception { - final byte[] bytes = Files.readAllBytes(archivePath); - final ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN); - final byte[] magic = new byte[4]; - buffer.get(magic); - assertEquals("PPA1", new String(magic, StandardCharsets.UTF_8)); - assertEquals(1, buffer.getInt()); - final int headerLength = buffer.getInt(); - final int payloadOffset = buffer.getInt(); - buffer.getInt(); - buffer.getInt(); - final String headerJson = new String(bytes, 24, headerLength, StandardCharsets.UTF_8); - return new ParsedArchive(MAPPER.readValue(headerJson, Map.class), payloadOffset); - } - - private Path createProject(Path projectRoot) throws Exception { - final Path atlasRoot = projectRoot.resolve("assets/ui/atlas"); - final Path soundsRoot = projectRoot.resolve("assets/audio/ui_sounds"); - Files.createDirectories(atlasRoot.resolve("sprites")); - Files.createDirectories(soundsRoot.resolve("sources")); - Files.createDirectories(projectRoot.resolve("assets/.prometeu")); - Files.writeString(atlasRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "name": "ui_atlas", - "type": "image_bank", - "inputs": { "sprites": ["sprites/confirm.png"] }, - "output": { "format": "TILES/indexed_v1", "codec": "RAW" }, - "preload": { "enabled": true } - } - """); - Files.writeString(soundsRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "name": "ui_sounds", - "type": "sound_bank", - "inputs": { "sources": ["sources/confirm.wav"] }, - "output": { "format": "SOUND/bank_v1", "codec": "RAW" }, - "preload": { "enabled": false } - } - """); - Files.writeString(atlasRoot.resolve("sprites/confirm.png"), "png"); - Files.writeString(soundsRoot.resolve("sources/confirm.wav"), "wav"); - Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """ - { - "schema_version": 1, - "next_asset_id": 3, - "assets": [ - { - "asset_id": 1, - "asset_uuid": "uuid-1", - "root": "ui/atlas" - }, - { - "asset_id": 2, - "asset_uuid": "uuid-2", - "root": "audio/ui_sounds" - } - ] - } - """); - return projectRoot; - } - - private record ParsedArchive( - Map header, - int payloadOffset) { - } -} diff --git a/prometeu-packer/src/test/java/p/packer/building/PackerBuildPlannerTest.java b/prometeu-packer/src/test/java/p/packer/building/PackerBuildPlannerTest.java deleted file mode 100644 index 9a762ba1..00000000 --- a/prometeu-packer/src/test/java/p/packer/building/PackerBuildPlannerTest.java +++ /dev/null @@ -1,125 +0,0 @@ -package p.packer.building; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import p.packer.api.PackerOperationStatus; -import p.packer.api.PackerProjectContext; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.*; - -final class PackerBuildPlannerTest { - @TempDir - Path tempDir; - - @Test - void equivalentProjectsProduceEquivalentBuildPlans() throws Exception { - final Path left = createProject(tempDir.resolve("left"), false); - final Path right = createProject(tempDir.resolve("right"), true); - final PackerBuildPlanner planner = new PackerBuildPlanner(); - - final PackerBuildPlan leftPlan = planner.plan(new PackerProjectContext("left", left)).plan(); - final PackerBuildPlan rightPlan = planner.plan(new PackerProjectContext("right", right)).plan(); - - assertNotNull(leftPlan); - assertNotNull(rightPlan); - assertEquals(leftPlan.cacheKey(), rightPlan.cacheKey()); - assertEquals(leftPlan.assetTableJson(), rightPlan.assetTableJson()); - assertEquals(leftPlan.preloadJson(), rightPlan.preloadJson()); - } - - @Test - void assetOrderingIsDeterministicByAssetId() throws Exception { - final Path projectRoot = createProject(tempDir.resolve("ordered"), true); - final PackerBuildPlanner planner = new PackerBuildPlanner(); - - final PackerBuildPlan plan = planner.plan(new PackerProjectContext("ordered", projectRoot)).plan(); - - assertEquals(2, plan.assets().size()); - assertEquals(1, plan.assets().get(0).assetId()); - assertEquals(2, plan.assets().get(1).assetId()); - assertTrue(plan.assetTableJson().contains("\"asset_id\":1")); - assertTrue(plan.assetTableJson().indexOf("\"asset_id\":1") < plan.assetTableJson().indexOf("\"asset_id\":2")); - } - - @Test - void missingManagedInputsBlockBuildPlanning() throws Exception { - final Path projectRoot = createProject(tempDir.resolve("broken"), false); - Files.delete(projectRoot.resolve("assets/ui/atlas/sprites/confirm.png")); - final PackerBuildPlanner planner = new PackerBuildPlanner(); - - final var result = planner.plan(new PackerProjectContext("broken", projectRoot)); - - assertEquals(PackerOperationStatus.FAILED, result.status()); - assertNull(result.plan()); - assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("Build input is missing"))); - } - - private Path createProject(Path projectRoot, boolean reverseRegistryOrder) throws Exception { - final Path atlasRoot = projectRoot.resolve("assets/ui/atlas"); - final Path soundsRoot = projectRoot.resolve("assets/audio/ui_sounds"); - Files.createDirectories(atlasRoot.resolve("sprites")); - Files.createDirectories(soundsRoot.resolve("sources")); - Files.createDirectories(projectRoot.resolve("assets/.prometeu")); - Files.writeString(atlasRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "name": "ui_atlas", - "type": "image_bank", - "inputs": { "sprites": ["sprites/confirm.png"] }, - "output": { "format": "TILES/indexed_v1", "codec": "RAW" }, - "preload": { "enabled": true } - } - """); - Files.writeString(soundsRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "name": "ui_sounds", - "type": "sound_bank", - "inputs": { "sources": ["sources/confirm.wav"] }, - "output": { "format": "SOUND/bank_v1", "codec": "RAW" }, - "preload": { "enabled": false } - } - """); - Files.writeString(atlasRoot.resolve("sprites/confirm.png"), "png"); - Files.writeString(soundsRoot.resolve("sources/confirm.wav"), "wav"); - Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), reverseRegistryOrder ? """ - { - "schema_version": 1, - "next_asset_id": 3, - "assets": [ - { - "asset_id": 2, - "asset_uuid": "uuid-2", - "root": "audio/ui_sounds" - }, - { - "asset_id": 1, - "asset_uuid": "uuid-1", - "root": "ui/atlas" - } - ] - } - """ : """ - { - "schema_version": 1, - "next_asset_id": 3, - "assets": [ - { - "asset_id": 1, - "asset_uuid": "uuid-1", - "root": "ui/atlas" - }, - { - "asset_id": 2, - "asset_uuid": "uuid-2", - "root": "audio/ui_sounds" - } - ] - } - """); - return projectRoot; - } -} diff --git a/prometeu-packer/src/test/java/p/packer/doctor/FileSystemPackerDoctorServiceTest.java b/prometeu-packer/src/test/java/p/packer/doctor/FileSystemPackerDoctorServiceTest.java deleted file mode 100644 index 97dd2a13..00000000 --- a/prometeu-packer/src/test/java/p/packer/doctor/FileSystemPackerDoctorServiceTest.java +++ /dev/null @@ -1,182 +0,0 @@ -package p.packer.doctor; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import p.packer.api.PackerOperationStatus; -import p.packer.api.PackerProjectContext; -import p.packer.api.diagnostics.PackerDiagnosticCategory; -import p.packer.api.doctor.PackerDoctorMode; -import p.packer.api.doctor.PackerDoctorRequest; -import p.packer.api.events.PackerEvent; -import p.packer.api.events.PackerEventKind; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; - -import static org.junit.jupiter.api.Assertions.*; - -final class FileSystemPackerDoctorServiceTest { - @TempDir - Path tempDir; - - @Test - void managedWorldReportsBlockingDiagnosticsForMissingManagedInputs() throws Exception { - final Path projectRoot = createManagedProjectWithMissingInput(); - final FileSystemPackerDoctorService service = new FileSystemPackerDoctorService(); - - final var result = service.doctor(new PackerDoctorRequest( - new PackerProjectContext("main", projectRoot), - PackerDoctorMode.MANAGED_WORLD, - false)); - - assertEquals(PackerOperationStatus.FAILED, result.status()); - assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> - diagnostic.category() == PackerDiagnosticCategory.STRUCTURAL - && diagnostic.blocking() - && diagnostic.message().contains("Declared input is missing"))); - assertTrue(result.safeFixes().isEmpty()); - } - - @Test - void expandedWorkspaceReportsUnregisteredAssetsAndRegisterSafeFixes() throws Exception { - final Path projectRoot = createExpandedWorkspace(); - final FileSystemPackerDoctorService service = new FileSystemPackerDoctorService(); - - final var result = service.doctor(new PackerDoctorRequest( - new PackerProjectContext("main", projectRoot), - PackerDoctorMode.EXPANDED_WORKSPACE, - true)); - - assertEquals(PackerOperationStatus.PARTIAL, result.status()); - assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> - diagnostic.category() == PackerDiagnosticCategory.HYGIENE - && diagnostic.message().contains("not registered, so it stays excluded from builds"))); - assertTrue(result.safeFixes().contains("register_asset orphans/ui_sounds")); - } - - @Test - void managedWorldIgnoresOrphanOnlyHygieneFindings() throws Exception { - final Path projectRoot = tempDir.resolve("orphan-only"); - final Path orphanRoot = projectRoot.resolve("assets/orphans/ui_sounds"); - Files.createDirectories(orphanRoot); - Files.writeString(orphanRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "name": "ui_sounds", - "type": "sound_bank", - "inputs": { "sources": ["confirm.wav"] }, - "output": { "format": "SOUND/bank_v1", "codec": "RAW" }, - "preload": { "enabled": false } - } - """); - Files.writeString(orphanRoot.resolve("confirm.wav"), "beep"); - - final FileSystemPackerDoctorService service = new FileSystemPackerDoctorService(); - - final var result = service.doctor(new PackerDoctorRequest( - new PackerProjectContext("main", projectRoot), - PackerDoctorMode.MANAGED_WORLD, - true)); - - assertEquals(PackerOperationStatus.SUCCESS, result.status()); - assertTrue(result.diagnostics().isEmpty()); - assertTrue(result.safeFixes().isEmpty()); - } - - @Test - void emitsDiagnosticsLifecycleEvents() throws Exception { - final Path projectRoot = createManagedProjectWithMissingInput(); - final List events = new CopyOnWriteArrayList<>(); - final FileSystemPackerDoctorService service = new FileSystemPackerDoctorService( - new p.packer.workspace.FileSystemPackerWorkspaceService(), - new p.packer.declarations.PackerAssetDetailsService(), - events::add); - - service.doctor(new PackerDoctorRequest( - new PackerProjectContext("main", projectRoot), - PackerDoctorMode.MANAGED_WORLD, - false)); - - assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.PROGRESS_UPDATED)); - assertEquals(PackerEventKind.DIAGNOSTICS_UPDATED, events.getLast().kind()); - } - - private Path createManagedProjectWithMissingInput() throws Exception { - final Path projectRoot = tempDir.resolve("managed-missing-input"); - final Path assetRoot = projectRoot.resolve("assets/ui/atlas"); - Files.createDirectories(assetRoot); - Files.createDirectories(projectRoot.resolve("assets/.prometeu")); - Files.writeString(assetRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "name": "ui_atlas", - "type": "image_bank", - "inputs": { "sprites": ["sprites/confirm.png"] }, - "output": { "format": "TILES/indexed_v1", "codec": "RAW" }, - "preload": { "enabled": true } - } - """); - Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """ - { - "schema_version": 1, - "next_asset_id": 2, - "assets": [ - { - "asset_id": 1, - "asset_uuid": "uuid-1", - "root": "ui/atlas" - } - ] - } - """); - return projectRoot; - } - - private Path createExpandedWorkspace() throws Exception { - final Path projectRoot = tempDir.resolve("expanded"); - final Path managedRoot = projectRoot.resolve("assets/ui/atlas"); - Files.createDirectories(managedRoot); - Files.createDirectories(projectRoot.resolve("assets/.prometeu")); - Files.createDirectories(managedRoot.resolve("sprites")); - Files.writeString(managedRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "name": "ui_atlas", - "type": "image_bank", - "inputs": { "sprites": ["sprites/confirm.png"] }, - "output": { "format": "TILES/indexed_v1", "codec": "RAW" }, - "preload": { "enabled": true } - } - """); - Files.writeString(managedRoot.resolve("sprites/confirm.png"), "png"); - Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """ - { - "schema_version": 1, - "next_asset_id": 2, - "assets": [ - { - "asset_id": 1, - "asset_uuid": "uuid-1", - "root": "ui/atlas" - } - ] - } - """); - final Path orphanRoot = projectRoot.resolve("assets/orphans/ui_sounds"); - Files.createDirectories(orphanRoot); - Files.writeString(orphanRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "name": "ui_sounds", - "type": "sound_bank", - "inputs": { "sources": ["confirm.wav"] }, - "output": { "format": "SOUND/bank_v1", "codec": "RAW" }, - "preload": { "enabled": false } - } - """); - Files.writeString(orphanRoot.resolve("confirm.wav"), "beep"); - return projectRoot; - } -} diff --git a/prometeu-packer/src/test/java/p/packer/mutations/FileSystemPackerMutationServiceTest.java b/prometeu-packer/src/test/java/p/packer/mutations/FileSystemPackerMutationServiceTest.java deleted file mode 100644 index 2cbf2dfb..00000000 --- a/prometeu-packer/src/test/java/p/packer/mutations/FileSystemPackerMutationServiceTest.java +++ /dev/null @@ -1,171 +0,0 @@ -package p.packer.mutations; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import p.packer.api.PackerProjectContext; -import p.packer.api.events.PackerEvent; -import p.packer.api.events.PackerEventKind; -import p.packer.api.mutations.PackerMutationPreview; -import p.packer.api.mutations.PackerMutationRequest; -import p.packer.api.mutations.PackerMutationType; -import p.packer.declarations.PackerAssetDetailsService; -import p.packer.foundation.PackerWorkspaceFoundation; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Comparator; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; - -import static org.junit.jupiter.api.Assertions.*; - -final class FileSystemPackerMutationServiceTest { - @TempDir - Path tempDir; - - @Test - void applyRelocatePreservesIdentityAndUpdatesRegistryRoot() 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.RELOCATE_ASSET, - "1", - null)); - - assertTrue(preview.canApply()); - assertTrue(preview.highRisk()); - 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")); - assertTrue(registryJson.contains("\"asset_id\" : 1")); - assertTrue(registryJson.contains("\"asset_uuid\" : \"uuid-1\"")); - assertTrue(registryJson.contains(relativeAssetRoot(project, preview.targetAssetRoot()))); - } - - @Test - void previewRegisterBlocksInvalidAssetDeclarations() throws Exception { - final Path projectRoot = tempDir.resolve("invalid"); - final Path assetRoot = projectRoot.resolve("assets/ui/broken"); - Files.createDirectories(assetRoot); - Files.writeString(assetRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "type": "image_bank" - } - """); - - final FileSystemPackerMutationService service = service(new CopyOnWriteArrayList<>()); - - final PackerMutationPreview preview = service.preview(new PackerMutationRequest( - project(projectRoot), - PackerMutationType.REGISTER_ASSET, - "ui/broken", - null)); - - assertFalse(preview.canApply()); - assertTrue(preview.blockers().stream().anyMatch(message -> message.contains("valid before registration"))); - } - - @Test - void applyExcludeFromBuildPreservesIdentityAndMarksRegistryEntryExcluded() throws Exception { - final Path projectRoot = createManagedAssetProject(); - final FileSystemPackerMutationService service = service(new CopyOnWriteArrayList<>()); - final PackerProjectContext project = project(projectRoot); - - final PackerMutationPreview preview = service.preview(new PackerMutationRequest( - project, - PackerMutationType.EXCLUDE_ASSET_FROM_BUILD, - "1", - null)); - - assertTrue(preview.canApply()); - service.apply(preview); - - final String registryJson = Files.readString(projectRoot.resolve("assets/.prometeu/index.json")); - assertTrue(registryJson.contains("\"asset_id\" : 1")); - assertTrue(registryJson.contains("\"included_in_build\" : false")); - assertTrue(Files.isDirectory(projectRoot.resolve("assets/ui/atlas"))); - } - - @Test - void emitsFailureLifecycleWhenApplyFails() throws Exception { - final Path projectRoot = createManagedAssetProject(); - final List events = new CopyOnWriteArrayList<>(); - final FileSystemPackerMutationService service = service(events); - final PackerMutationPreview preview = service.preview(new PackerMutationRequest( - project(projectRoot), - PackerMutationType.RELOCATE_ASSET, - "1", - null)); - - deleteRecursively(projectRoot.resolve("assets/ui/atlas")); - - assertThrows(UncheckedIOException.class, () -> service.apply(preview)); - assertEquals(PackerEventKind.ACTION_FAILED, events.getLast().kind()); - assertEquals(preview.operationId(), events.getLast().operationId()); - } - - private FileSystemPackerMutationService service(List events) { - return new FileSystemPackerMutationService( - new PackerWorkspaceFoundation(), - new PackerAssetDetailsService(), - new PackerProjectWriteCoordinator(), - events::add); - } - - private Path createManagedAssetProject() throws Exception { - final Path projectRoot = tempDir.resolve("main"); - final Path assetRoot = projectRoot.resolve("assets/ui/atlas"); - Files.createDirectories(assetRoot); - Files.createDirectories(projectRoot.resolve("assets/.prometeu")); - Files.writeString(assetRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "name": "ui_atlas", - "type": "image_bank", - "preload": { "enabled": true } - } - """); - Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """ - { - "schema_version": 1, - "next_asset_id": 2, - "assets": [ - { - "asset_id": 1, - "asset_uuid": "uuid-1", - "root": "ui/atlas", - "included_in_build": true - } - ] - } - """); - return projectRoot; - } - - private PackerProjectContext project(Path root) { - return new PackerProjectContext("main", root); - } - - private String relativeAssetRoot(PackerProjectContext project, Path assetRoot) { - return project.rootPath().resolve("assets").toAbsolutePath().normalize() - .relativize(assetRoot.toAbsolutePath().normalize()) - .toString() - .replace('\\', '/'); - } - - private void deleteRecursively(Path root) throws IOException { - try (var stream = Files.walk(root)) { - for (Path path : stream.sorted(Comparator.reverseOrder()).toList()) { - Files.deleteIfExists(path); - } - } - } -} diff --git a/prometeu-packer/src/test/java/p/packer/mutations/PackerProjectWriteCoordinatorTest.java b/prometeu-packer/src/test/java/p/packer/mutations/PackerProjectWriteCoordinatorTest.java deleted file mode 100644 index 17078c4d..00000000 --- a/prometeu-packer/src/test/java/p/packer/mutations/PackerProjectWriteCoordinatorTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package p.packer.mutations; - -import org.junit.jupiter.api.Test; -import p.packer.api.PackerProjectContext; - -import java.nio.file.Path; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -final class PackerProjectWriteCoordinatorTest { - @Test - void serializesWritesWithinTheSameProject() throws Exception { - final PackerProjectWriteCoordinator coordinator = new PackerProjectWriteCoordinator(); - final PackerProjectContext project = new PackerProjectContext("main", Path.of("/tmp/main")); - final AtomicInteger concurrentWriters = new AtomicInteger(); - final AtomicInteger maxConcurrentWriters = new AtomicInteger(); - final CountDownLatch firstEntered = new CountDownLatch(1); - final CountDownLatch releaseFirst = new CountDownLatch(1); - - try (ExecutorService executor = Executors.newFixedThreadPool(2)) { - final Future first = executor.submit(() -> coordinator.withWriteLock(project, () -> { - final int current = concurrentWriters.incrementAndGet(); - maxConcurrentWriters.accumulateAndGet(current, Math::max); - firstEntered.countDown(); - await(releaseFirst); - concurrentWriters.decrementAndGet(); - return null; - })); - - assertTrue(firstEntered.await(2, TimeUnit.SECONDS)); - - final Future second = executor.submit(() -> coordinator.withWriteLock(project, () -> { - final int current = concurrentWriters.incrementAndGet(); - maxConcurrentWriters.accumulateAndGet(current, Math::max); - concurrentWriters.decrementAndGet(); - return null; - })); - - Thread.sleep(100L); - assertFalse(second.isDone()); - releaseFirst.countDown(); - first.get(2, TimeUnit.SECONDS); - second.get(2, TimeUnit.SECONDS); - } - - assertEquals(1, maxConcurrentWriters.get()); - } - - private void await(CountDownLatch latch) { - try { - latch.await(2, TimeUnit.SECONDS); - } catch (InterruptedException interruptedException) { - Thread.currentThread().interrupt(); - throw new AssertionError(interruptedException); - } - } -} diff --git a/prometeu-packer/src/test/java/p/packer/workspace/FileSystemPackerWorkspaceServiceTest.java b/prometeu-packer/src/test/java/p/packer/workspace/FileSystemPackerWorkspaceServiceTest.java deleted file mode 100644 index e3b11dd9..00000000 --- a/prometeu-packer/src/test/java/p/packer/workspace/FileSystemPackerWorkspaceServiceTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package p.packer.workspace; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import p.packer.api.PackerOperationStatus; -import p.packer.api.PackerProjectContext; -import p.packer.api.assets.PackerBuildParticipation; -import p.packer.api.assets.PackerAssetState; -import p.packer.api.events.PackerEvent; -import p.packer.api.events.PackerEventKind; -import p.packer.api.workspace.ListAssetsRequest; -import p.packer.testing.PackerFixtureLocator; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Comparator; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; - -import static org.junit.jupiter.api.Assertions.*; - -final class FileSystemPackerWorkspaceServiceTest { - @TempDir - Path tempDir; - - @Test - void listsRegisteredAndUnregisteredAssetsFromWorkspaceScan() throws Exception { - final Path projectRoot = copyFixture("workspaces/read-mixed", tempDir.resolve("mixed")); - final FileSystemPackerWorkspaceService service = new FileSystemPackerWorkspaceService(); - - final var result = service.listAssets(new ListAssetsRequest(project(projectRoot))); - - assertEquals(PackerOperationStatus.SUCCESS, result.status()); - assertEquals(2, result.assets().size()); - assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.REGISTERED)); - assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.UNREGISTERED)); - } - - @Test - void surfacesMissingRegisteredRootAsStructuralDiagnostic() throws Exception { - final Path projectRoot = copyFixture("workspaces/read-missing-root", tempDir.resolve("missing-root")); - final FileSystemPackerWorkspaceService service = new FileSystemPackerWorkspaceService(); - - final var result = service.listAssets(new ListAssetsRequest(project(projectRoot))); - - assertEquals(PackerOperationStatus.PARTIAL, result.status()); - assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("missing asset.json"))); - } - - @Test - void includesInvalidDeclarationsInSnapshotWithDiagnostics() throws Exception { - final Path projectRoot = copyFixture("workspaces/read-invalid", tempDir.resolve("invalid")); - final FileSystemPackerWorkspaceService service = new FileSystemPackerWorkspaceService(); - - final var result = service.listAssets(new ListAssetsRequest(project(projectRoot))); - - assertEquals(1, result.assets().size()); - assertEquals(PackerAssetState.UNREGISTERED, result.assets().getFirst().state()); - assertEquals(PackerBuildParticipation.EXCLUDED, result.assets().getFirst().buildParticipation()); - assertTrue(result.assets().getFirst().hasDiagnostics()); - } - - @Test - void emitsDiscoveryAndDiagnosticsEventsDuringScan() throws Exception { - final Path projectRoot = copyFixture("workspaces/read-invalid", tempDir.resolve("events")); - final List events = new CopyOnWriteArrayList<>(); - final FileSystemPackerWorkspaceService service = new FileSystemPackerWorkspaceService( - new p.packer.foundation.PackerWorkspaceFoundation(), - new p.packer.declarations.PackerAssetDeclarationParser(), - events::add); - - service.listAssets(new ListAssetsRequest(project(projectRoot))); - - assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.ASSET_DISCOVERED)); - assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.DIAGNOSTICS_UPDATED)); - assertTrue(events.stream().allMatch(event -> event.sequence() >= 0L)); - } - - private PackerProjectContext project(Path root) { - return new PackerProjectContext("main", root); - } - - private Path copyFixture(String relativePath, Path targetRoot) throws Exception { - final Path sourceRoot = PackerFixtureLocator.fixtureRoot(relativePath); - try (var stream = Files.walk(sourceRoot)) { - for (Path source : stream.sorted(Comparator.naturalOrder()).toList()) { - final Path target = targetRoot.resolve(sourceRoot.relativize(source).toString()); - if (Files.isDirectory(source)) { - Files.createDirectories(target); - } else { - Files.createDirectories(target.getParent()); - Files.copy(source, target); - } - } - } - return targetRoot; - } -} diff --git a/prometeu-studio/build.gradle.kts b/prometeu-studio/build.gradle.kts index e57251e0..16fc90df 100644 --- a/prometeu-studio/build.gradle.kts +++ b/prometeu-studio/build.gradle.kts @@ -1,11 +1,11 @@ plugins { - id("gradle.java-application-conventions") + id("gradle.java-library-conventions") alias(libs.plugins.javafx) } dependencies { implementation(project(":prometeu-infra")) - implementation(project(":prometeu-packer")) + implementation(project(":prometeu-packer:prometeu-packer-api")) implementation(project(":prometeu-compiler:prometeu-compiler-core")) implementation(project(":prometeu-compiler:prometeu-build-pipeline")) implementation(project(":prometeu-compiler:prometeu-frontend-registry")) @@ -18,7 +18,3 @@ javafx { version = libs.versions.javafx.get() modules("javafx.controls", "javafx.fxml") } - -application { - mainClass = "p.studio.App" -} diff --git a/prometeu-studio/src/main/java/p/studio/Container.java b/prometeu-studio/src/main/java/p/studio/Container.java index 2ee7e1f7..091b052e 100644 --- a/prometeu-studio/src/main/java/p/studio/Container.java +++ b/prometeu-studio/src/main/java/p/studio/Container.java @@ -1,26 +1,98 @@ package p.studio; +import com.fasterxml.jackson.databind.ObjectMapper; import p.studio.events.StudioEventBus; import p.studio.utilities.ThemeService; import p.studio.utilities.i18n.I18nService; -public class Container { - private static final I18nService i18nService; - private static final ThemeService themeService; - private static final StudioEventBus studioEventBus; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; - static { - i18nService = new I18nService(); - themeService = new ThemeService(); - studioEventBus = new StudioEventBus(); +public interface Container { + I18nService getI18n(); + + ThemeService getTheme(); + + StudioEventBus getEventBus(); + + ObjectMapper getMapper(); + + Packer getPacker(); + + StudioBackgroundTasks getBackgroundTasks(); + + void shutdownContainer(); + + static void install(Container container) { + final Container candidate = Objects.requireNonNull(container, "container"); + final Container current = Holder.CURRENT.get(); + if (current == null) { + if (Holder.CURRENT.compareAndSet(null, candidate)) { + return; + } + } + if (Holder.CURRENT.get() != candidate) { + throw new IllegalStateException("Container already installed."); + } } - public static void init() { + static Container current() { + final Container container = Holder.CURRENT.get(); + if (container == null) { + throw new IllegalStateException("Container has not been installed."); + } + return container; } - public static I18nService i18n() { return i18nService; } + static I18nService i18n() { + return current().getI18n(); + } - public static ThemeService theme() { return new ThemeService(); } + static ThemeService theme() { + return current().getTheme(); + } - public static StudioEventBus events() { return studioEventBus; } + static StudioEventBus eventBus() { + return current().getEventBus(); + } + + static ObjectMapper mapper() { + return current().getMapper(); + } + + static Packer packer() { + return current().getPacker(); + } + + static StudioBackgroundTasks backgroundTasks() { + return current().getBackgroundTasks(); + } + + static void shutdown() { + final Container container = Holder.CURRENT.get(); + if (container != null) { + container.shutdownContainer(); + } + } + + record Packer( + p.packer.PackerWorkspaceService workspaceService, + Runnable shutdownAction) { + + public Packer { + Objects.requireNonNull(workspaceService, "workspaceService"); + Objects.requireNonNull(shutdownAction, "shutdownAction"); + } + + public void shutdown() { + shutdownAction.run(); + } + } + + final class Holder { + private static final AtomicReference CURRENT = new AtomicReference<>(); + + private Holder() { + } + } } diff --git a/prometeu-studio/src/main/java/p/studio/StudioBackgroundTasks.java b/prometeu-studio/src/main/java/p/studio/StudioBackgroundTasks.java new file mode 100644 index 00000000..f97a4c38 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/StudioBackgroundTasks.java @@ -0,0 +1,30 @@ +package p.studio; + +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +public final class StudioBackgroundTasks { + private final ExecutorService executorService; + + public StudioBackgroundTasks(ExecutorService executorService) { + this.executorService = Objects.requireNonNull(executorService, "executorService"); + } + + public Future submit(Runnable task) { + return executorService.submit(Objects.requireNonNull(task, "task")); + } + + public void shutdown() { + executorService.shutdown(); + try { + if (!executorService.awaitTermination(2, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + } catch (InterruptedException interruptedException) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/controls/forms/StudioFormActionBar.java b/prometeu-studio/src/main/java/p/studio/controls/forms/StudioFormActionBar.java new file mode 100644 index 00000000..83cf7e0f --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/controls/forms/StudioFormActionBar.java @@ -0,0 +1,53 @@ +package p.studio.controls.forms; + +import javafx.scene.control.Button; +import javafx.scene.layout.HBox; +import p.studio.Container; +import p.studio.utilities.i18n.I18n; + +public final class StudioFormActionBar extends HBox { + private final Button changeButton = createButton(I18n.FORM_ACTION_CHANGE); + private final Button applyButton = createButton(I18n.FORM_ACTION_APPLY); + private final Button resetButton = createButton(I18n.FORM_ACTION_RESET); + private final Button cancelButton = createButton(I18n.FORM_ACTION_CANCEL); + + public StudioFormActionBar( + Runnable onChange, + Runnable onApply, + Runnable onReset, + Runnable onCancel) { + setSpacing(8); + getStyleClass().add("studio-form-actions"); + changeButton.getStyleClass().add("studio-button-secondary"); + applyButton.getStyleClass().add("studio-button-primary"); + resetButton.getStyleClass().add("studio-button-secondary"); + cancelButton.getStyleClass().add("studio-button-cancel"); + changeButton.setOnAction(ignored -> onChange.run()); + applyButton.setOnAction(ignored -> onApply.run()); + resetButton.setOnAction(ignored -> onReset.run()); + cancelButton.setOnAction(ignored -> onCancel.run()); + getChildren().setAll(changeButton, applyButton, resetButton, cancelButton); + } + + public void updateState(StudioFormMode mode, boolean dirty) { + final boolean editing = mode == StudioFormMode.EDITING; + setVisibleManaged(changeButton, !editing); + setVisibleManaged(applyButton, editing); + setVisibleManaged(resetButton, editing); + setVisibleManaged(cancelButton, editing); + applyButton.setDisable(!dirty); + resetButton.setDisable(!dirty); + } + + private Button createButton(I18n label) { + final Button button = new Button(); + button.textProperty().bind(Container.i18n().bind(label)); + button.getStyleClass().add("studio-button"); + return button; + } + + private void setVisibleManaged(Button button, boolean visible) { + button.setVisible(visible); + button.setManaged(visible); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/controls/forms/StudioFormMode.java b/prometeu-studio/src/main/java/p/studio/controls/forms/StudioFormMode.java new file mode 100644 index 00000000..0f2f8414 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/controls/forms/StudioFormMode.java @@ -0,0 +1,6 @@ +package p.studio.controls.forms; + +public enum StudioFormMode { + READ_ONLY, + EDITING +} diff --git a/prometeu-studio/src/main/java/p/studio/controls/forms/StudioFormSession.java b/prometeu-studio/src/main/java/p/studio/controls/forms/StudioFormSession.java new file mode 100644 index 00000000..a0e866b6 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/controls/forms/StudioFormSession.java @@ -0,0 +1,58 @@ +package p.studio.controls.forms; + +import java.util.Objects; +import java.util.function.UnaryOperator; + +public final class StudioFormSession { + private T source; + private T draft; + private StudioFormMode mode; + + public StudioFormSession(T source) { + replaceSource(source); + } + + public void replaceSource(T source) { + this.source = Objects.requireNonNull(source, "source"); + this.draft = source; + this.mode = StudioFormMode.READ_ONLY; + } + + public T source() { + return source; + } + + public T draft() { + return draft; + } + + public StudioFormMode mode() { + return mode; + } + + public boolean isDirty() { + return !Objects.equals(source, draft); + } + + public void beginEdit() { + mode = StudioFormMode.EDITING; + } + + public void setDraft(T draft) { + this.draft = Objects.requireNonNull(draft, "draft"); + } + + public void updateDraft(UnaryOperator update) { + draft = Objects.requireNonNull(update, "update").apply(draft); + draft = Objects.requireNonNull(draft, "draft"); + } + + public void resetDraft() { + draft = source; + } + + public void cancelEdit() { + resetDraft(); + mode = StudioFormMode.READ_ONLY; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityEventMapper.java b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityEventMapper.java index 1037bdf9..b0619324 100644 --- a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityEventMapper.java +++ b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityEventMapper.java @@ -1,6 +1,9 @@ package p.studio.controls.shell; import p.studio.events.*; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshFailedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshStartedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshedEvent; import java.util.Optional; @@ -10,12 +13,12 @@ public final class StudioActivityEventMapper { public static Optional map(StudioEvent event) { return switch (event) { - case StudioProjectOpenedEvent opened -> - Optional.of(new StudioActivityEntry("Studio", "Project opened: " + opened.project().name(), StudioActivityEntrySeverity.SUCCESS, false)); - case StudioProjectLoadingStartedEvent started -> - Optional.of(new StudioActivityEntry("Studio", "Project loading started: " + started.project().name(), StudioActivityEntrySeverity.INFO, false)); - case StudioProjectLoadingCompletedEvent completed -> - Optional.of(new StudioActivityEntry("Studio", "Project ready: " + completed.project().name(), StudioActivityEntrySeverity.SUCCESS, false)); + case StudioProjectOpenedEvent ignored -> + Optional.of(new StudioActivityEntry("Studio", "Project opened", StudioActivityEntrySeverity.SUCCESS, false)); + case StudioProjectLoadingStartedEvent ignored -> + Optional.of(new StudioActivityEntry("Studio", "Project loading started", StudioActivityEntrySeverity.INFO, false)); + case StudioProjectLoadingCompletedEvent ignored -> + Optional.of(new StudioActivityEntry("Studio", "Project ready", StudioActivityEntrySeverity.SUCCESS, false)); case StudioProjectLoadingFailedEvent failed -> Optional.of(new StudioActivityEntry("Studio", failed.message(), StudioActivityEntrySeverity.ERROR, true)); case StudioAssetsWorkspaceRefreshStartedEvent ignored -> @@ -24,22 +27,15 @@ public final class StudioActivityEventMapper { Optional.of(new StudioActivityEntry("Assets", refreshed.assetCount() + " assets loaded", StudioActivityEntrySeverity.SUCCESS, false)); case StudioAssetsWorkspaceRefreshFailedEvent failed -> Optional.of(new StudioActivityEntry("Assets", failed.message(), StudioActivityEntrySeverity.ERROR, true)); - case StudioAssetsMutationPreviewReadyEvent previewReady -> - Optional.of(new StudioActivityEntry("Assets", "Preview ready: " + previewReady.action().name().toLowerCase(), StudioActivityEntrySeverity.INFO, false)); - case StudioAssetsMutationAppliedEvent applied -> - Optional.of(new StudioActivityEntry("Assets", "Action applied: " + applied.action().name().toLowerCase(), StudioActivityEntrySeverity.SUCCESS, false)); - case StudioAssetsMutationFailedEvent failed -> - Optional.of(new StudioActivityEntry("Assets", failed.message(), StudioActivityEntrySeverity.ERROR, true)); case StudioPackerOperationEvent packerEvent -> - Optional.of(new StudioActivityEntry("Assets", packerEvent.summary(), severity(packerEvent), packerEvent.kind() == p.packer.api.events.PackerEventKind.ACTION_FAILED)); + Optional.of(new StudioActivityEntry("Assets", packerEvent.summary(), severity(packerEvent), packerEvent.kind() == p.packer.events.PackerEventKind.ACTION_FAILED)); default -> Optional.empty(); }; } private static StudioActivityEntrySeverity severity(StudioPackerOperationEvent event) { return switch (event.kind()) { - case BUILD_FINISHED, CACHE_HIT -> StudioActivityEntrySeverity.SUCCESS; - case DIAGNOSTICS_UPDATED, CACHE_MISS, BUILD_STARTED, ASSET_DISCOVERED, ASSET_CHANGED, PROGRESS_UPDATED -> StudioActivityEntrySeverity.INFO; + case ACTION_APPLIED -> StudioActivityEntrySeverity.SUCCESS; case ACTION_FAILED -> StudioActivityEntrySeverity.ERROR; default -> StudioActivityEntrySeverity.INFO; }; diff --git a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityFeedControl.java b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityFeedControl.java index 29248931..45b213b1 100644 --- a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityFeedControl.java +++ b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityFeedControl.java @@ -14,21 +14,34 @@ import p.studio.Container; import p.studio.controls.lifecycle.StudioControlLifecycle; import p.studio.controls.lifecycle.StudioControlLifecycleSupport; import p.studio.events.*; +import p.studio.projects.ProjectReference; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshFailedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshStartedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshedEvent; import p.studio.utilities.events.EventSubscription; import p.studio.utilities.i18n.I18n; import java.util.ArrayList; import java.util.List; +import java.util.Objects; public final class StudioActivityFeedControl extends VBox implements StudioControlLifecycle { + private final ProjectReference projectReference; + private final StudioActivityStorageService storageService; private final ObservableList entries = FXCollections.observableArrayList(); private final List subscriptions = new ArrayList<>(); private final Label progressLabel = new Label(); private final ProgressBar progressBar = new ProgressBar(); private final ListView listView = new ListView<>(entries); - public StudioActivityFeedControl() { + public StudioActivityFeedControl(ProjectReference projectReference) { + this(projectReference, new StudioActivityStorageService()); + } + + StudioActivityFeedControl(ProjectReference projectReference, StudioActivityStorageService storageService) { StudioControlLifecycleSupport.install(this, this); + this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); + this.storageService = Objects.requireNonNull(storageService, "storageService"); getStyleClass().add("studio-activity-feed"); setSpacing(10); setPadding(new Insets(12)); @@ -42,12 +55,16 @@ public final class StudioActivityFeedControl extends VBox implements StudioContr listView.getStyleClass().add("studio-activity-list"); listView.setCellFactory(ignored -> new ActivityCell()); + entries.setAll(this.storageService.load(projectReference)); getChildren().addAll(progressLabel, progressBar, listView); } @Override public void subscribe() { - final StudioEventBus eventBus = Container.events(); + if (!subscriptions.isEmpty()) { + return; + } + final StudioEventBus eventBus = Container.eventBus(); subscriptions.add(eventBus.subscribe(StudioProjectOpenedEvent.class, this::onEvent)); subscriptions.add(eventBus.subscribe(StudioProjectLoadingStartedEvent.class, this::onEvent)); subscriptions.add(eventBus.subscribe(StudioProjectLoadingProgressEvent.class, this::onProjectLoadingProgress)); @@ -56,9 +73,6 @@ public final class StudioActivityFeedControl extends VBox implements StudioContr subscriptions.add(eventBus.subscribe(StudioAssetsWorkspaceRefreshStartedEvent.class, this::onAssetsRefreshStarted)); subscriptions.add(eventBus.subscribe(StudioAssetsWorkspaceRefreshedEvent.class, this::onAssetsRefreshFinished)); subscriptions.add(eventBus.subscribe(StudioAssetsWorkspaceRefreshFailedEvent.class, this::onAssetsRefreshFailed)); - subscriptions.add(eventBus.subscribe(StudioAssetsMutationPreviewReadyEvent.class, this::onEvent)); - subscriptions.add(eventBus.subscribe(StudioAssetsMutationAppliedEvent.class, this::onEvent)); - subscriptions.add(eventBus.subscribe(StudioAssetsMutationFailedEvent.class, this::onEvent)); subscriptions.add(eventBus.subscribe(StudioPackerOperationEvent.class, this::onPackerOperation)); } @@ -124,10 +138,9 @@ public final class StudioActivityFeedControl extends VBox implements StudioContr progressBar.setManaged(true); progressBar.setProgress(event.indeterminate() ? ProgressBar.INDETERMINATE_PROGRESS : event.progress()); }); - if (event.kind() == p.packer.api.events.PackerEventKind.BUILD_FINISHED) { - clearProgress(); - } - } else if (event.kind() == p.packer.api.events.PackerEventKind.BUILD_FINISHED) { + } + if (event.kind() == p.packer.events.PackerEventKind.ACTION_APPLIED + || event.kind() == p.packer.events.PackerEventKind.ACTION_FAILED) { clearProgress(); } } @@ -143,17 +156,18 @@ public final class StudioActivityFeedControl extends VBox implements StudioContr private void appendEntry(StudioActivityEntry entry) { Platform.runLater(() -> { if (!entries.isEmpty()) { - final StudioActivityEntry last = entries.getLast(); - if (last.source().equals(entry.source()) - && last.message().equals(entry.message()) - && last.severity() == entry.severity()) { + final StudioActivityEntry latest = entries.getFirst(); + if (latest.source().equals(entry.source()) + && latest.message().equals(entry.message()) + && latest.severity() == entry.severity()) { return; } } entries.addFirst(entry); - while (entries.size() > 100) { + while (entries.size() > StudioActivityStorageService.MAX_HISTORY) { entries.removeLast(); } + storageService.save(projectReference, List.copyOf(entries)); }); } diff --git a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityStorageService.java b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityStorageService.java new file mode 100644 index 00000000..599f5970 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityStorageService.java @@ -0,0 +1,106 @@ +package p.studio.controls.shell; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import p.studio.projects.ProjectReference; +import p.studio.projects.ProjectStudioPaths; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class StudioActivityStorageService { + public static final int MAX_HISTORY = 500; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + public List load(ProjectReference projectReference) { + final Path activitiesPath = ProjectStudioPaths.activitiesPath(projectReference); + if (!Files.isRegularFile(activitiesPath)) { + return List.of(); + } + + try { + final PersistedActivityEntry[] persistedEntries = MAPPER.readValue( + activitiesPath.toFile(), + PersistedActivityEntry[].class); + final List entries = new ArrayList<>(); + for (PersistedActivityEntry persistedEntry : persistedEntries) { + final StudioActivityEntry mapped = map(persistedEntry); + if (mapped != null) { + entries.add(mapped); + } + } + return bounded(entries); + } catch (IOException | RuntimeException ignored) { + return List.of(); + } + } + + public void save(ProjectReference projectReference, List entries) { + final Path activitiesPath = ProjectStudioPaths.activitiesPath(projectReference); + final List boundedEntries = bounded(entries); + final List persistedEntries = boundedEntries.stream() + .map(entry -> new PersistedActivityEntry( + entry.source(), + entry.message(), + entry.severity().name(), + entry.sticky())) + .toList(); + + try { + Files.createDirectories(activitiesPath.getParent()); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(activitiesPath.toFile(), persistedEntries); + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + } + + public List bounded(List entries) { + final List normalized = List.copyOf(Objects.requireNonNull(entries, "entries")); + if (normalized.size() <= MAX_HISTORY) { + return normalized; + } + return List.copyOf(normalized.subList(0, MAX_HISTORY)); + } + + private StudioActivityEntry map(PersistedActivityEntry persistedEntry) { + if (persistedEntry == null + || persistedEntry.source == null + || persistedEntry.message == null + || persistedEntry.severity == null) { + return null; + } + try { + return new StudioActivityEntry( + persistedEntry.source, + persistedEntry.message, + StudioActivityEntrySeverity.valueOf(persistedEntry.severity.trim()), + persistedEntry.sticky); + } catch (RuntimeException ignored) { + return null; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class PersistedActivityEntry { + public String source; + public String message; + public String severity; + public boolean sticky; + + public PersistedActivityEntry() { + } + + private PersistedActivityEntry(String source, String message, String severity, boolean sticky) { + this.source = source; + this.message = message; + this.severity = severity; + this.sticky = sticky; + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsAssetSummaryPatchedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsAssetSummaryPatchedEvent.java deleted file mode 100644 index 51a2bd4f..00000000 --- a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsAssetSummaryPatchedEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -package p.studio.events; - -import p.studio.projects.ProjectReference; -import p.studio.workspaces.assets.AssetWorkspaceAssetSummary; - -import java.util.Objects; - -public record StudioAssetsAssetSummaryPatchedEvent( - ProjectReference project, - AssetWorkspaceAssetSummary summary) implements StudioEvent { - public StudioAssetsAssetSummaryPatchedEvent { - Objects.requireNonNull(project, "project"); - Objects.requireNonNull(summary, "summary"); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationAppliedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationAppliedEvent.java deleted file mode 100644 index 54192837..00000000 --- a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationAppliedEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package p.studio.events; - -import p.studio.projects.ProjectReference; -import p.studio.workspaces.assets.AssetWorkspaceAction; - -import java.util.Objects; - -public record StudioAssetsMutationAppliedEvent( - ProjectReference project, - AssetWorkspaceAction action, - int affectedAssets) implements StudioEvent { - public StudioAssetsMutationAppliedEvent { - Objects.requireNonNull(project, "project"); - Objects.requireNonNull(action, "action"); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationFailedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationFailedEvent.java deleted file mode 100644 index cab75386..00000000 --- a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationFailedEvent.java +++ /dev/null @@ -1,17 +0,0 @@ -package p.studio.events; - -import p.studio.projects.ProjectReference; -import p.studio.workspaces.assets.AssetWorkspaceAction; - -import java.util.Objects; - -public record StudioAssetsMutationFailedEvent( - ProjectReference project, - AssetWorkspaceAction action, - String message) implements StudioEvent { - public StudioAssetsMutationFailedEvent { - Objects.requireNonNull(project, "project"); - Objects.requireNonNull(action, "action"); - Objects.requireNonNull(message, "message"); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationPreviewReadyEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationPreviewReadyEvent.java deleted file mode 100644 index 9b9fbd79..00000000 --- a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationPreviewReadyEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package p.studio.events; - -import p.studio.projects.ProjectReference; -import p.studio.workspaces.assets.AssetWorkspaceAction; - -import java.util.Objects; - -public record StudioAssetsMutationPreviewReadyEvent( - ProjectReference project, - AssetWorkspaceAction action, - int affectedAssets) implements StudioEvent { - public StudioAssetsMutationPreviewReadyEvent { - Objects.requireNonNull(project, "project"); - Objects.requireNonNull(action, "action"); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsNavigatorViewStateChangedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsNavigatorViewStateChangedEvent.java deleted file mode 100644 index 7e3f975c..00000000 --- a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsNavigatorViewStateChangedEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -package p.studio.events; - -import p.studio.projects.ProjectReference; -import p.studio.workspaces.assets.AssetWorkspaceNavigatorViewState; - -import java.util.Objects; - -public record StudioAssetsNavigatorViewStateChangedEvent( - ProjectReference project, - AssetWorkspaceNavigatorViewState viewState) implements StudioEvent { - public StudioAssetsNavigatorViewStateChangedEvent { - Objects.requireNonNull(project, "project"); - Objects.requireNonNull(viewState, "viewState"); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsStructuralSyncRequestedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsStructuralSyncRequestedEvent.java deleted file mode 100644 index 72f96bba..00000000 --- a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsStructuralSyncRequestedEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package p.studio.events; - -import p.studio.projects.ProjectReference; -import p.studio.workspaces.assets.AssetWorkspaceSelectionKey; - -import java.util.Objects; - -public record StudioAssetsStructuralSyncRequestedEvent( - ProjectReference project, - AssetWorkspaceSelectionKey preferredSelectionKey, - String reason) implements StudioEvent { - public StudioAssetsStructuralSyncRequestedEvent { - Objects.requireNonNull(project, "project"); - Objects.requireNonNull(reason, "reason"); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceRefreshFailedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceRefreshFailedEvent.java deleted file mode 100644 index 05f62d6c..00000000 --- a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceRefreshFailedEvent.java +++ /dev/null @@ -1,12 +0,0 @@ -package p.studio.events; - -import p.studio.projects.ProjectReference; - -import java.util.Objects; - -public record StudioAssetsWorkspaceRefreshFailedEvent(ProjectReference project, String message) implements StudioEvent { - public StudioAssetsWorkspaceRefreshFailedEvent { - Objects.requireNonNull(project, "project"); - Objects.requireNonNull(message, "message"); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceRefreshStartedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceRefreshStartedEvent.java deleted file mode 100644 index d3031957..00000000 --- a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceRefreshStartedEvent.java +++ /dev/null @@ -1,11 +0,0 @@ -package p.studio.events; - -import p.studio.projects.ProjectReference; - -import java.util.Objects; - -public record StudioAssetsWorkspaceRefreshStartedEvent(ProjectReference project) implements StudioEvent { - public StudioAssetsWorkspaceRefreshStartedEvent { - Objects.requireNonNull(project, "project"); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceRefreshedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceRefreshedEvent.java deleted file mode 100644 index 38863711..00000000 --- a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceRefreshedEvent.java +++ /dev/null @@ -1,14 +0,0 @@ -package p.studio.events; - -import p.studio.projects.ProjectReference; - -import java.util.Objects; - -public record StudioAssetsWorkspaceRefreshedEvent(ProjectReference project, int assetCount) implements StudioEvent { - public StudioAssetsWorkspaceRefreshedEvent { - Objects.requireNonNull(project, "project"); - if (assetCount < 0) { - throw new IllegalArgumentException("assetCount must not be negative"); - } - } -} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceSelectionChangedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceSelectionChangedEvent.java deleted file mode 100644 index 77965783..00000000 --- a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceSelectionChangedEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -package p.studio.events; - -import p.studio.projects.ProjectReference; -import p.studio.workspaces.assets.AssetWorkspaceSelectionKey; - -import java.util.Objects; - -public record StudioAssetsWorkspaceSelectionChangedEvent( - ProjectReference project, - AssetWorkspaceSelectionKey selectionKey) implements StudioEvent { - public StudioAssetsWorkspaceSelectionChangedEvent { - Objects.requireNonNull(project, "project"); - Objects.requireNonNull(selectionKey, "selectionKey"); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceSelectionRequestedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceSelectionRequestedEvent.java deleted file mode 100644 index 142ad45c..00000000 --- a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceSelectionRequestedEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -package p.studio.events; - -import p.studio.projects.ProjectReference; -import p.studio.workspaces.assets.AssetWorkspaceSelectionKey; - -import java.util.Objects; - -public record StudioAssetsWorkspaceSelectionRequestedEvent( - ProjectReference project, - AssetWorkspaceSelectionKey selectionKey) implements StudioEvent { - public StudioAssetsWorkspaceSelectionRequestedEvent { - Objects.requireNonNull(project, "project"); - Objects.requireNonNull(selectionKey, "selectionKey"); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioPackerEventAdapter.java b/prometeu-studio/src/main/java/p/studio/events/StudioPackerEventAdapter.java index 40453be8..986b0a2c 100644 --- a/prometeu-studio/src/main/java/p/studio/events/StudioPackerEventAdapter.java +++ b/prometeu-studio/src/main/java/p/studio/events/StudioPackerEventAdapter.java @@ -1,39 +1,25 @@ package p.studio.events; -import p.packer.api.events.PackerEvent; -import p.packer.api.events.PackerEventKind; -import p.packer.api.events.PackerEventSink; -import p.studio.projects.ProjectReference; +import p.packer.events.PackerEvent; +import p.packer.events.PackerEventSink; import java.util.Objects; public final class StudioPackerEventAdapter implements PackerEventSink { - private final StudioWorkspaceEventBus eventBus; - private final ProjectReference projectReference; + private final StudioEventBus eventBus; - public StudioPackerEventAdapter(StudioWorkspaceEventBus eventBus, ProjectReference projectReference) { + public StudioPackerEventAdapter(StudioEventBus eventBus) { this.eventBus = Objects.requireNonNull(eventBus, "eventBus"); - this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); } @Override public void publish(PackerEvent event) { final PackerEvent packerEvent = Objects.requireNonNull(event, "event"); - if (ignore(packerEvent.kind())) { - return; - } eventBus.publish(new StudioPackerOperationEvent( - projectReference, packerEvent.operationId(), packerEvent.kind(), packerEvent.summary(), packerEvent.progress() == null ? null : packerEvent.progress().value(), packerEvent.progress() == null || packerEvent.progress().indeterminate())); } - - private boolean ignore(PackerEventKind kind) { - return kind == PackerEventKind.PREVIEW_READY - || kind == PackerEventKind.ACTION_APPLIED - || kind == PackerEventKind.ACTION_FAILED; - } } diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioPackerOperationEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioPackerOperationEvent.java index 84fd17f7..dfdf13f9 100644 --- a/prometeu-studio/src/main/java/p/studio/events/StudioPackerOperationEvent.java +++ b/prometeu-studio/src/main/java/p/studio/events/StudioPackerOperationEvent.java @@ -1,12 +1,10 @@ package p.studio.events; -import p.packer.api.events.PackerEventKind; -import p.studio.projects.ProjectReference; +import p.packer.events.PackerEventKind; import java.util.Objects; public record StudioPackerOperationEvent( - ProjectReference project, String operationId, PackerEventKind kind, String summary, @@ -14,7 +12,6 @@ public record StudioPackerOperationEvent( boolean indeterminate) implements StudioEvent { public StudioPackerOperationEvent { - Objects.requireNonNull(project, "project"); operationId = Objects.requireNonNull(operationId, "operationId").trim(); Objects.requireNonNull(kind, "kind"); summary = Objects.requireNonNull(summary, "summary").trim(); diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioProjectCreatedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioProjectCreatedEvent.java index db24ebde..7e96623b 100644 --- a/prometeu-studio/src/main/java/p/studio/events/StudioProjectCreatedEvent.java +++ b/prometeu-studio/src/main/java/p/studio/events/StudioProjectCreatedEvent.java @@ -1,6 +1,4 @@ package p.studio.events; -import p.studio.projects.ProjectReference; - -public record StudioProjectCreatedEvent(ProjectReference project) implements StudioEvent { +public record StudioProjectCreatedEvent() implements StudioEvent { } diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingCompletedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingCompletedEvent.java index 40de1681..7797f8dc 100644 --- a/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingCompletedEvent.java +++ b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingCompletedEvent.java @@ -1,11 +1,4 @@ package p.studio.events; -import p.studio.projects.ProjectReference; - -import java.util.Objects; - -public record StudioProjectLoadingCompletedEvent(ProjectReference project) implements StudioEvent { - public StudioProjectLoadingCompletedEvent { - Objects.requireNonNull(project, "project"); - } +public record StudioProjectLoadingCompletedEvent() implements StudioEvent { } diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingFailedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingFailedEvent.java index 29aef0ef..bd9b1332 100644 --- a/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingFailedEvent.java +++ b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingFailedEvent.java @@ -1,16 +1,12 @@ package p.studio.events; -import p.studio.projects.ProjectReference; - import java.util.Objects; public record StudioProjectLoadingFailedEvent( - ProjectReference project, StudioProjectLoadingPhase phase, String message) implements StudioEvent { public StudioProjectLoadingFailedEvent { - Objects.requireNonNull(project, "project"); Objects.requireNonNull(phase, "phase"); Objects.requireNonNull(message, "message"); } diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingProgressEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingProgressEvent.java index 471f4ec3..5b80a8b8 100644 --- a/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingProgressEvent.java +++ b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingProgressEvent.java @@ -1,18 +1,14 @@ package p.studio.events; -import p.studio.projects.ProjectReference; - import java.util.Objects; public record StudioProjectLoadingProgressEvent( - ProjectReference project, StudioProjectLoadingPhase phase, String message, double progress, boolean indeterminate) implements StudioEvent { public StudioProjectLoadingProgressEvent { - Objects.requireNonNull(project, "project"); Objects.requireNonNull(phase, "phase"); Objects.requireNonNull(message, "message"); if (!indeterminate && (progress < 0.0d || progress > 1.0d)) { diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingStartedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingStartedEvent.java index 2efa4bab..bc624d31 100644 --- a/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingStartedEvent.java +++ b/prometeu-studio/src/main/java/p/studio/events/StudioProjectLoadingStartedEvent.java @@ -1,11 +1,4 @@ package p.studio.events; -import p.studio.projects.ProjectReference; - -import java.util.Objects; - -public record StudioProjectLoadingStartedEvent(ProjectReference project) implements StudioEvent { - public StudioProjectLoadingStartedEvent { - Objects.requireNonNull(project, "project"); - } +public record StudioProjectLoadingStartedEvent() implements StudioEvent { } diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioProjectOpenedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioProjectOpenedEvent.java index 08c2d35b..622f098d 100644 --- a/prometeu-studio/src/main/java/p/studio/events/StudioProjectOpenedEvent.java +++ b/prometeu-studio/src/main/java/p/studio/events/StudioProjectOpenedEvent.java @@ -1,6 +1,4 @@ package p.studio.events; -import p.studio.projects.ProjectReference; - -public record StudioProjectOpenedEvent(ProjectReference project) implements StudioEvent { +public record StudioProjectOpenedEvent() implements StudioEvent { } diff --git a/prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java b/prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java index ee6e7ba9..79944088 100644 --- a/prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java +++ b/prometeu-studio/src/main/java/p/studio/projects/ProjectCatalogService.java @@ -104,6 +104,12 @@ public final class ProjectCatalogService { try { Files.createDirectories(normalizedParent); Files.createDirectories(projectRoot.resolve(".workspace")); + Files.createDirectories(ProjectStudioPaths.studioRoot(new ProjectReference( + displayName, + "1.0.0", + languageId, + request.stdlib(), + projectRoot))); Files.createDirectories(projectRoot.resolve(sourceRoot)); Files.createDirectories(projectRoot.resolve("assets")); Files.createDirectories(projectRoot.resolve("build")); diff --git a/prometeu-studio/src/main/java/p/studio/projects/ProjectReference.java b/prometeu-studio/src/main/java/p/studio/projects/ProjectReference.java index 4f0cb945..53209d49 100644 --- a/prometeu-studio/src/main/java/p/studio/projects/ProjectReference.java +++ b/prometeu-studio/src/main/java/p/studio/projects/ProjectReference.java @@ -1,5 +1,8 @@ package p.studio.projects; +import org.apache.commons.lang3.StringUtils; +import p.packer.PackerProjectContext; + import java.nio.file.Path; public record ProjectReference( @@ -12,4 +15,24 @@ public record ProjectReference( public String toString() { return name; } + + private Path absoluteRootPath() { + return rootPath().toAbsolutePath().normalize(); + } + + private String displayRelativePath(final String resolve) { + final Path normalizedPath = rootPath.toAbsolutePath().normalize(); + try { + final var path = StringUtils.isNotBlank(resolve) + ? absoluteRootPath().resolve(resolve) + : absoluteRootPath(); + return path.relativize(normalizedPath).toString().replace('\\', '/'); + } catch (IllegalArgumentException ignored) { + return normalizedPath.toString().replace('\\', '/'); + } + } + + public PackerProjectContext toPackerProjectContext() { + return new PackerProjectContext(name, absoluteRootPath()); + } } diff --git a/prometeu-studio/src/main/java/p/studio/projects/ProjectStudioPaths.java b/prometeu-studio/src/main/java/p/studio/projects/ProjectStudioPaths.java new file mode 100644 index 00000000..55630aa2 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/projects/ProjectStudioPaths.java @@ -0,0 +1,27 @@ +package p.studio.projects; + +import java.nio.file.Path; +import java.util.Objects; + +public final class ProjectStudioPaths { + private static final String STUDIO_DIR = ".studio"; + private static final String ACTIVITIES_FILE = "activities.json"; + + private ProjectStudioPaths() { + } + + public static Path studioRoot(ProjectReference projectReference) { + return Objects.requireNonNull(projectReference, "projectReference") + .rootPath() + .resolve(STUDIO_DIR) + .toAbsolutePath() + .normalize(); + } + + public static Path activitiesPath(ProjectReference projectReference) { + return studioRoot(projectReference) + .resolve(ACTIVITIES_FILE) + .toAbsolutePath() + .normalize(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java index c2c09feb..c0e20820 100644 --- a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java +++ b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java @@ -79,12 +79,9 @@ public enum I18n { WORKSPACE_ASSETS("workspace.assets"), ASSETS_NAVIGATOR_TITLE("assets.navigator.title"), + ASSETS_NAVIGATOR_ACTION_REFRESH("assets.navigator.action.refresh"), ASSETS_NAVIGATOR_ACTION_ADD("assets.navigator.action.add"), - ASSETS_NAVIGATOR_ACTION_DOCTOR("assets.navigator.action.doctor"), - ASSETS_NAVIGATOR_ACTION_PACK("assets.navigator.action.pack"), - ASSETS_DETAILS_TITLE("assets.details.title"), ASSETS_SEARCH_PROMPT("assets.search.prompt"), - ASSETS_FILTER_REGISTERED("assets.filter.registered"), ASSETS_FILTER_UNREGISTERED("assets.filter.unregistered"), ASSETS_FILTER_DIAGNOSTICS("assets.filter.diagnostics"), ASSETS_FILTER_PRELOAD("assets.filter.preload"), @@ -99,6 +96,7 @@ public enum I18n { ASSETS_BADGE_DIAGNOSTICS("assets.badge.diagnostics"), ASSETS_SECTION_SUMMARY("assets.section.summary"), ASSETS_SECTION_RUNTIME_CONTRACT("assets.section.runtimeContract"), + ASSETS_SUBSECTION_CODEC_CONFIGURATION("assets.subsection.codecConfiguration"), ASSETS_SECTION_INPUTS_PREVIEW("assets.section.inputsPreview"), ASSETS_SECTION_DIAGNOSTICS("assets.section.diagnostics"), ASSETS_SECTION_ACTIONS("assets.section.actions"), @@ -125,12 +123,17 @@ public enum I18n { ASSETS_MUTATION_APPLY("assets.mutation.apply"), ASSETS_MUTATION_CONFIRM_TITLE("assets.mutation.confirm.title"), ASSETS_MUTATION_CONFIRM_HEADER("assets.mutation.confirm.header"), + FORM_ACTION_CHANGE("form.action.change"), + FORM_ACTION_APPLY("form.action.apply"), + FORM_ACTION_RESET("form.action.reset"), + FORM_ACTION_CANCEL("form.action.cancel"), ASSETS_LABEL_NAME("assets.label.name"), ASSETS_LABEL_REGISTRATION("assets.label.registration"), ASSETS_LABEL_BUILD_PARTICIPATION("assets.label.buildParticipation"), ASSETS_LABEL_ASSET_ID("assets.label.assetId"), ASSETS_LABEL_TYPE("assets.label.type"), ASSETS_LABEL_LOCATION("assets.label.location"), + ASSETS_LABEL_BANK("assets.label.bank"), ASSETS_LABEL_TARGET_LOCATION("assets.label.targetLocation"), ASSETS_LABEL_FORMAT("assets.label.format"), ASSETS_LABEL_CODEC("assets.label.codec"), @@ -163,6 +166,7 @@ public enum I18n { ASSETS_DETAILS_EMPTY("assets.details.empty"), ASSETS_DETAILS_READY("assets.details.ready"), ASSETS_DETAILS_NO_SELECTION("assets.details.noSelection"), + ASSETS_DETAILS_CODEC_CONFIGURATION_EMPTY("assets.details.codecConfiguration.empty"), 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"), @@ -186,6 +190,9 @@ public enum I18n { 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_ROOT_RELATIVE("assets.addWizard.error.rootRelative"), + ASSETS_ADD_WIZARD_ERROR_ROOT_OUTSIDE_ASSETS("assets.addWizard.error.rootOutsideAssets"), + ASSETS_ADD_WIZARD_ERROR_ROOT_ALREADY_ASSET("assets.addWizard.error.rootAlreadyAsset"), 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"), diff --git a/prometeu-studio/src/main/java/p/studio/utilities/logspane/LogsPane.java b/prometeu-studio/src/main/java/p/studio/utilities/logspane/LogsPane.java new file mode 100644 index 00000000..e737fc68 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/utilities/logspane/LogsPane.java @@ -0,0 +1,63 @@ +package p.studio.utilities.logspane; + +import javafx.application.Platform; +import javafx.beans.value.ObservableValue; +import javafx.scene.control.TextArea; +import javafx.scene.control.TitledPane; +import p.studio.controls.lifecycle.StudioControlLifecycle; +import p.studio.controls.lifecycle.StudioControlLifecycleSupport; + +import java.util.Objects; + +public abstract class LogsPane implements StudioControlLifecycle { + private final TextArea textArea; + private final TitledPane pane; + + protected LogsPane( + ObservableValue titleBinding, + String workspace) { + this(titleBinding, workspace, true); + } + + protected LogsPane( + ObservableValue titleBinding, + String workspace, + boolean installLifecycle) { + final String workspaceName = Objects.requireNonNull(workspace, "workspace").trim(); + if (workspaceName.isBlank()) { + throw new IllegalArgumentException("workspace must not be blank"); + } + + this.textArea = new TextArea(); + textArea.setEditable(false); + textArea.setWrapText(true); + textArea.setPrefRowCount(8); + textArea.getStyleClass().add("%s-workspace-logs".formatted(workspaceName)); + + this.pane = new TitledPane(); + pane.textProperty().bind(Objects.requireNonNull(titleBinding, "titleBinding")); + pane.setContent(textArea); + pane.setCollapsible(true); + pane.setExpanded(true); + pane.getStyleClass().add("%s-workspace-logs-pane".formatted(workspaceName)); + if (installLifecycle) { + StudioControlLifecycleSupport.install(pane, this); + } + } + + public final TitledPane getPane() { + return pane; + } + + protected final void appendLine(String line) { + final String normalized = Objects.requireNonNullElse(line, "").stripTrailing(); + if (normalized.isBlank()) { + return; + } + Platform.runLater(() -> textArea.appendText(normalized + System.lineSeparator())); + } + + protected final void clearLogs() { + Platform.runLater(textArea::clear); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/window/MainView.java b/prometeu-studio/src/main/java/p/studio/window/MainView.java index 8af9bd65..3fbe4aad 100644 --- a/prometeu-studio/src/main/java/p/studio/window/MainView.java +++ b/prometeu-studio/src/main/java/p/studio/window/MainView.java @@ -6,11 +6,10 @@ import p.studio.controls.shell.*; import p.studio.events.StudioWorkspaceSelectedEvent; import p.studio.projects.ProjectReference; import p.studio.utilities.i18n.I18n; -import p.studio.workspaces.PlaceholderWorkspace; import p.studio.workspaces.WorkspaceHost; import p.studio.workspaces.WorkspaceId; import p.studio.workspaces.assets.AssetWorkspace; -import p.studio.workspaces.builder.BuilderWorkspace; +import p.studio.workspaces.builder.ShipperWorkspace; import p.studio.workspaces.editor.EditorWorkspace; import java.util.List; @@ -19,16 +18,16 @@ public final class MainView extends BorderPane { private final WorkspaceHost host = new WorkspaceHost(); private final ProjectReference projectReference; - public MainView(ProjectReference projectReference) { + public MainView(final ProjectReference projectReference) { this.projectReference = projectReference; final var menuBar = new StudioShellMenuBarControl(); final var runSurface = new StudioRunSurfaceControl(); setTop(new StudioShellTopBarControl(menuBar)); - host.register(new EditorWorkspace()); host.register(new AssetWorkspace(projectReference)); - host.register(new BuilderWorkspace(projectReference)); - host.register(new PlaceholderWorkspace(WorkspaceId.DEBUG, I18n.WORKSPACE_DEBUG, "Debug")); + host.register(new EditorWorkspace(projectReference)); +// host.register(new PlaceholderWorkspace(WorkspaceId.DEBUG, I18n.WORKSPACE_DEBUG, "Debug")); + host.register(new ShipperWorkspace(projectReference)); final var workspaceRail = new StudioWorkspaceRailControl<>( List.of( @@ -37,25 +36,25 @@ public final class MainView extends BorderPane { new StudioWorkspaceRailItem<>(WorkspaceId.DEBUG, "🎮", Container.i18n().bind(I18n.WORKSPACE_DEBUG)), new StudioWorkspaceRailItem<>(WorkspaceId.SHIPPER, "⚙️", Container.i18n().bind(I18n.WORKSPACE_SHIPPER)) ), - this::showWorkspace); + this::loadWorkspace); setLeft(workspaceRail); setCenter(host); setRight(new StudioRightUtilityPanelControl( runSurface, Container.i18n().bind(I18n.SHELL_ACTIVITY), - new StudioActivityFeedControl())); + new StudioActivityFeedControl(projectReference))); // default workspaceRail.select(WorkspaceId.ASSETS); - showWorkspace(WorkspaceId.ASSETS); + loadWorkspace(WorkspaceId.ASSETS); } public ProjectReference projectReference() { return projectReference; } - private void showWorkspace(WorkspaceId workspaceId) { - host.show(workspaceId); - Container.events().publish(new StudioWorkspaceSelectedEvent(workspaceId)); + private void loadWorkspace(WorkspaceId workspaceId) { + host.change(workspaceId); + Container.eventBus().publish(new StudioWorkspaceSelectedEvent(workspaceId)); } } diff --git a/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java b/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java index 1cd9b0eb..e6b86c5b 100644 --- a/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java +++ b/prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java @@ -1,11 +1,16 @@ package p.studio.window; -import javafx.animation.PauseTransition; +import javafx.application.Platform; import javafx.scene.Scene; import javafx.stage.Stage; import javafx.stage.StageStyle; -import javafx.util.Duration; +import p.packer.messages.InitWorkspaceRequest; import p.studio.Container; +import p.studio.events.StudioProjectLoadingCompletedEvent; +import p.studio.events.StudioProjectLoadingFailedEvent; +import p.studio.events.StudioProjectLoadingPhase; +import p.studio.events.StudioProjectLoadingProgressEvent; +import p.studio.events.StudioProjectLoadingStartedEvent; import p.studio.events.StudioProjectCreatedEvent; import p.studio.events.StudioProjectOpenedEvent; import p.studio.projects.KnownProjectsService; @@ -23,7 +28,6 @@ public final class StudioWindowCoordinator { private static final double PROJECT_LOADING_HEIGHT = 250; private static final double PROJECT_WIDTH = 1280; private static final double PROJECT_HEIGHT = 840; - private static final Duration PROJECT_OPEN_DELAY = Duration.seconds(1.5); private final Stage launcherStage; private final ProjectCatalogService projectCatalogService; @@ -67,14 +71,14 @@ public final class StudioWindowCoordinator { private void openProject(ProjectReference projectReference) { final ProjectReference opened = projectCatalogService.openProject(projectReference.rootPath()); knownProjectsService.remember(opened); - Container.events().publish(new StudioProjectOpenedEvent(opened)); + Container.eventBus().publish(new StudioProjectOpenedEvent()); openProjectWindow(opened); } private void createProject(ProjectReference projectReference) { knownProjectsService.remember(projectReference); - Container.events().publish(new StudioProjectCreatedEvent(projectReference)); - Container.events().publish(new StudioProjectOpenedEvent(projectReference)); + Container.eventBus().publish(new StudioProjectCreatedEvent()); + Container.eventBus().publish(new StudioProjectOpenedEvent()); openProjectWindow(projectReference); } @@ -90,31 +94,12 @@ public final class StudioWindowCoordinator { private void openProjectWindow(ProjectReference projectReference) { final Stage loadingStage = createProjectLoadingStage(projectReference); - final Stage projectStage = new Stage(); - final Scene scene = new Scene(new MainView(projectReference), PROJECT_WIDTH, PROJECT_HEIGHT); - scene.getStylesheets().add(Container.theme().getDefaultTheme()); - - projectStage.setTitle(Container.i18n().format(I18n.APP_PROJECT_TITLE, projectReference.name())); - projectStage.setScene(scene); - windowStateService.installProjectShellState(projectStage, projectReference, PROJECT_WIDTH, PROJECT_HEIGHT); - projectStage.setOnHidden(ignored -> { - launcherView.reloadProjects(); - launcherStage.show(); - launcherStage.centerOnScreen(); - launcherStage.toFront(); - }); - launcherStage.hide(); loadingStage.show(); loadingStage.centerOnScreen(); loadingStage.toFront(); - final PauseTransition delay = new PauseTransition(PROJECT_OPEN_DELAY); - delay.setOnFinished(ignored -> { - loadingStage.close(); - projectStage.show(); - projectStage.toFront(); - }); - delay.play(); + publishProjectLoadingStarted(projectReference); + Container.backgroundTasks().submit(() -> initializeProjectAndOpenWindow(projectReference, loadingStage)); } private Stage createProjectLoadingStage(ProjectReference projectReference) { @@ -131,6 +116,88 @@ public final class StudioWindowCoordinator { return loadingStage; } + private void initializeProjectAndOpenWindow(ProjectReference projectReference, Stage loadingStage) { + try { + Container.eventBus().publish(new StudioProjectLoadingProgressEvent( + StudioProjectLoadingPhase.INITIALIZING_SERVICES, + Container.i18n().text(I18n.SHIELD_STATUS_INDEXING), + Progress.INITIALIZING_SERVICES, + false)); + Container.packer() + .workspaceService() + .initWorkspace(new InitWorkspaceRequest(projectReference.toPackerProjectContext())); + Container.eventBus().publish(new StudioProjectLoadingProgressEvent( + StudioProjectLoadingPhase.RESTORING_WORKSPACES, + Container.i18n().text(I18n.SHIELD_STATUS_RESTORING), + Progress.RESTORING_WORKSPACES, + false)); + Platform.runLater(() -> finishProjectOpen(projectReference, loadingStage)); + } catch (RuntimeException exception) { + Platform.runLater(() -> failProjectOpen(projectReference, loadingStage, exception)); + } + } + + private void finishProjectOpen(ProjectReference projectReference, Stage loadingStage) { + final Stage projectStage = new Stage(); + final Scene scene = new Scene(new MainView(projectReference), PROJECT_WIDTH, PROJECT_HEIGHT); + scene.getStylesheets().add(Container.theme().getDefaultTheme()); + + projectStage.setTitle(Container.i18n().format(I18n.APP_PROJECT_TITLE, projectReference.name())); + projectStage.setScene(scene); + windowStateService.installProjectShellState(projectStage, projectReference, PROJECT_WIDTH, PROJECT_HEIGHT); + projectStage.setOnHidden(ignored -> { + launcherView.reloadProjects(); + launcherStage.show(); + launcherStage.centerOnScreen(); + launcherStage.toFront(); + }); + + Container.eventBus().publish(new StudioProjectLoadingProgressEvent( + StudioProjectLoadingPhase.READY, + Container.i18n().format(I18n.APP_PROJECT_TITLE, projectReference.name()), + Progress.READY, + false)); + Container.eventBus().publish(new StudioProjectLoadingCompletedEvent()); + loadingStage.close(); + projectStage.show(); + projectStage.toFront(); + } + + private void failProjectOpen( + ProjectReference projectReference, + Stage loadingStage, + RuntimeException exception) { + final String message = exception.getMessage() == null || exception.getMessage().isBlank() + ? "Unable to initialize project services for " + projectReference.name() + "." + : exception.getMessage(); + Container.eventBus().publish(new StudioProjectLoadingFailedEvent( + StudioProjectLoadingPhase.INITIALIZING_SERVICES, + message)); + loadingStage.close(); + launcherStage.show(); + launcherStage.centerOnScreen(); + launcherStage.toFront(); + } + + private void publishProjectLoadingStarted(ProjectReference projectReference) { + Container.eventBus().publish(new StudioProjectLoadingStartedEvent()); + Container.eventBus().publish(new StudioProjectLoadingProgressEvent( + StudioProjectLoadingPhase.RESOLVING_PROJECT, + Container.i18n().format(I18n.SHIELD_LOADING_PROJECT, projectReference.name()), + Progress.RESOLVING_PROJECT, + false)); + } + + private static final class Progress { + private static final double RESOLVING_PROJECT = 0.20d; + private static final double INITIALIZING_SERVICES = 0.58d; + private static final double RESTORING_WORKSPACES = 0.86d; + private static final double READY = 1.0d; + + private Progress() { + } + } + private Path resolveDefaultProjectsRoot() { Path cursor = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); while (cursor != null) { diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/PlaceholderWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/PlaceholderWorkspace.java deleted file mode 100644 index de1f9294..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/PlaceholderWorkspace.java +++ /dev/null @@ -1,22 +0,0 @@ -package p.studio.workspaces; - -import javafx.scene.Node; -import javafx.scene.control.Label; -import javafx.scene.layout.StackPane; -import p.studio.utilities.i18n.I18n; - -public final class PlaceholderWorkspace implements Workspace { - private final WorkspaceId id; - private final I18n title; - private final StackPane root = new StackPane(); - - public PlaceholderWorkspace(WorkspaceId id, I18n title, String label) { - this.id = id; - this.title = title; - root.getChildren().add(new Label(label + " (TODO)")); - } - - @Override public WorkspaceId id() { return id; } - @Override public I18n title() { return title; } - @Override public Node root() { return root; } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/StudioWorkspaceLifecycleSupport.java b/prometeu-studio/src/main/java/p/studio/workspaces/StudioWorkspaceLifecycleSupport.java new file mode 100644 index 00000000..b854ecdc --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/StudioWorkspaceLifecycleSupport.java @@ -0,0 +1,59 @@ +package p.studio.workspaces; + +import javafx.application.Platform; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.scene.Node; +import javafx.scene.Scene; +import p.studio.controls.lifecycle.StudioControlLifecycle; + +import java.util.List; +import java.util.Objects; + +public final class StudioWorkspaceLifecycleSupport { + private StudioWorkspaceLifecycleSupport() { + } + + public static void activate(Workspace workspace) { + final Workspace target = Objects.requireNonNull(workspace, "workspace"); + final Node rootNode = target.rootNode(); + final Scene scene = rootNode.getScene(); + if (scene != null) { + activateNow(target); + return; + } + + rootNode.sceneProperty().addListener(new ChangeListener<>() { + @Override + public void changed( + ObservableValue observable, + Scene oldScene, + Scene newScene) { + if (newScene == null) { + return; + } + rootNode.sceneProperty().removeListener(this); + activateNow(target); + } + }); + } + + public static void deactivate(Workspace workspace) { + final Workspace target = Objects.requireNonNull(workspace, "workspace"); + target.unLoad(); + final List participants = target.lifecycleParticipants(); + for (int index = participants.size() - 1; index >= 0; index--) { + participants.get(index).unsubscribe(); + } + } + + private static void activateNow(Workspace workspace) { + Platform.runLater(() -> { + final List participants = workspace.lifecycleParticipants(); + for (StudioControlLifecycle participant : participants) { + participant.subscribe(); + } + workspace.load(); + }); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/Workspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/Workspace.java index ff7eb0b6..4719d559 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/Workspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/Workspace.java @@ -1,12 +1,29 @@ package p.studio.workspaces; +import p.studio.Container; +import p.studio.controls.lifecycle.StudioControlLifecycle; +import p.studio.events.StudioWorkspaceEventBus; +import p.studio.projects.ProjectReference; import p.studio.utilities.i18n.I18n; -public interface Workspace { - WorkspaceId id(); - I18n title(); - javafx.scene.Node root(); - default void onShow() {} - default void onHide() {} -} +import java.util.List; +public abstract class Workspace { + protected final ProjectReference projectReference; + protected final StudioWorkspaceEventBus workspaceEventBus; + + public Workspace(final ProjectReference projectReference) { + this.projectReference = projectReference; + this.workspaceEventBus = new StudioWorkspaceEventBus(workspaceId(), Container.eventBus()); + } + + public abstract WorkspaceId workspaceId(); + public abstract I18n title(); + public abstract javafx.scene.Node rootNode(); + public abstract void load(); + public abstract void unLoad(); + + public List lifecycleParticipants() { + return List.of(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/WorkspaceHost.java b/prometeu-studio/src/main/java/p/studio/workspaces/WorkspaceHost.java index df004b70..8486cc1b 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/WorkspaceHost.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/WorkspaceHost.java @@ -2,44 +2,46 @@ package p.studio.workspaces; import javafx.scene.Node; import javafx.scene.layout.StackPane; +import lombok.Getter; import java.util.EnumMap; import java.util.Map; public final class WorkspaceHost extends StackPane { private final Map workspaces = new EnumMap<>(WorkspaceId.class); - private WorkspaceId active; + @Getter + private WorkspaceId currentWorkspaceId; public void register(Workspace ws) { - workspaces.put(ws.id(), ws); + workspaces.put(ws.workspaceId(), ws); - Node r = ws.root(); + Node r = ws.rootNode(); r.setVisible(false); r.setManaged(false); getChildren().add(r); } - public void show(WorkspaceId id) { - if (active == id) return; + public void change(final WorkspaceId id) { + if (currentWorkspaceId == id) return; - if (active != null) { - Workspace old = workspaces.get(active); - old.onHide(); - Node n = old.root(); + if (currentWorkspaceId != null) { + final var oldWorkspace = workspaces.get(currentWorkspaceId); + StudioWorkspaceLifecycleSupport.deactivate(oldWorkspace); + final var n = oldWorkspace.rootNode(); n.setVisible(false); n.setManaged(false); } - Workspace next = workspaces.get(id); - if (next == null) throw new IllegalStateException("Workspace not registered: " + id); + final var nextWorkspace = workspaces.get(id); + if (nextWorkspace == null) { + throw new IllegalStateException("Workspace not registered: " + id); + } - Node n = next.root(); + final var n = nextWorkspace.rootNode(); n.setVisible(true); n.setManaged(true); - next.onShow(); - active = id; + currentWorkspaceId = id; + StudioWorkspaceLifecycleSupport.activate(nextWorkspace); } - - public WorkspaceId active() { return active; } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationCatalog.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationCatalog.java deleted file mode 100644 index 59a780ec..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationCatalog.java +++ /dev/null @@ -1,67 +0,0 @@ -package p.studio.workspaces.assets; - -import java.util.List; -import java.util.Objects; - -public final class AssetCreationCatalog { - private static final List TYPES = List.of( - new AssetTypeOption( - "image_bank", - List.of( - new AssetFormatOption("TILES/indexed_v1", List.of("RAW")), - new AssetFormatOption("SPRITES/indexed_v1", List.of("RAW")))), - new AssetTypeOption( - "sound_bank", - List.of(new AssetFormatOption("AUDIO/pcm_v1", List.of("RAW")))), - new AssetTypeOption( - "palette_bank", - List.of(new AssetFormatOption("PALETTE/indexed_v1", List.of("RAW"))))); - - private AssetCreationCatalog() { - } - - public static List assetTypes() { - return TYPES.stream().map(AssetTypeOption::assetType).toList(); - } - - public static List outputFormatsFor(String assetType) { - return findType(assetType).formats().stream().map(AssetFormatOption::outputFormat).toList(); - } - - public static List outputCodecsFor(String assetType, String outputFormat) { - final AssetTypeOption type = findType(assetType); - return type.formats().stream() - .filter(format -> format.outputFormat().equals(outputFormat)) - .findFirst() - .map(AssetFormatOption::codecs) - .orElse(List.of()); - } - - public static boolean supports(String assetType, String outputFormat, String outputCodec) { - return outputCodecsFor(assetType, outputFormat).contains(outputCodec); - } - - private static AssetTypeOption findType(String assetType) { - final String normalized = Objects.requireNonNullElse(assetType, "").trim(); - return TYPES.stream() - .filter(type -> type.assetType().equals(normalized)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unsupported asset type: " + assetType)); - } - - record AssetTypeOption( - String assetType, - List formats) { - AssetTypeOption { - formats = List.copyOf(formats); - } - } - - record AssetFormatOption( - String outputFormat, - List codecs) { - AssetFormatOption { - codecs = List.copyOf(codecs); - } - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationRequest.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationRequest.java deleted file mode 100644 index 2914b7b0..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationRequest.java +++ /dev/null @@ -1,27 +0,0 @@ -package p.studio.workspaces.assets; - -import java.util.Objects; - -public record AssetCreationRequest( - String assetName, - String relativeRoot, - String assetType, - String outputFormat, - String outputCodec, - boolean preloadEnabled) { - - public AssetCreationRequest { - assetName = Objects.requireNonNull(assetName, "assetName").trim(); - relativeRoot = Objects.requireNonNull(relativeRoot, "relativeRoot").trim(); - assetType = Objects.requireNonNull(assetType, "assetType").trim(); - outputFormat = Objects.requireNonNull(outputFormat, "outputFormat").trim(); - outputCodec = Objects.requireNonNull(outputCodec, "outputCodec").trim(); - if (assetName.isBlank() - || relativeRoot.isBlank() - || assetType.isBlank() - || outputFormat.isBlank() - || outputCodec.isBlank()) { - throw new IllegalArgumentException("Asset creation fields must not be blank."); - } - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationResult.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationResult.java deleted file mode 100644 index a35bcf36..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationResult.java +++ /dev/null @@ -1,14 +0,0 @@ -package p.studio.workspaces.assets; - -import java.nio.file.Path; -import java.util.Objects; - -public record AssetCreationResult( - AssetWorkspaceSelectionKey selectionKey, - Path assetRoot) { - - public AssetCreationResult { - selectionKey = Objects.requireNonNull(selectionKey, "selectionKey"); - assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationService.java deleted file mode 100644 index 9cde0753..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationService.java +++ /dev/null @@ -1,7 +0,0 @@ -package p.studio.workspaces.assets; - -import p.studio.projects.ProjectReference; - -public interface AssetCreationService { - AssetCreationResult create(ProjectReference projectReference, AssetCreationRequest request); -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationWizard.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationWizard.java deleted file mode 100644 index d9fd2251..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetCreationWizard.java +++ /dev/null @@ -1,354 +0,0 @@ -package p.studio.workspaces.assets; - -import javafx.collections.FXCollections; -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.scene.Node; -import javafx.scene.Scene; -import javafx.scene.control.Button; -import javafx.scene.control.CheckBox; -import javafx.scene.control.ComboBox; -import javafx.scene.control.Label; -import javafx.scene.control.TextField; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.Region; -import javafx.scene.layout.VBox; -import javafx.stage.DirectoryChooser; -import javafx.stage.Modality; -import javafx.stage.Stage; -import javafx.stage.Window; -import p.studio.Container; -import p.studio.projects.ProjectReference; -import p.studio.utilities.i18n.I18n; - -import java.io.File; -import java.nio.file.Path; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicReference; - -public final class AssetCreationWizard { - private final AssetCreationService assetCreationService; - private final ProjectReference projectReference; - private final Stage stage; - private final AtomicReference result = new AtomicReference<>(); - - private final Label stepTitle = new Label(); - private final Label stepDescription = new Label(); - private final VBox stepBody = new VBox(12); - private final Label feedbackLabel = new Label(); - - private final TextField rootField = new TextField(); - private final TextField nameField = new TextField(); - private final ComboBox typeCombo = new ComboBox<>(); - private final ComboBox formatCombo = new ComboBox<>(); - private final ComboBox codecCombo = new ComboBox<>(); - private final CheckBox preloadField = new CheckBox(); - - private final Button backButton = new Button(); - private final Button nextButton = new Button(); - private final Button createButton = new Button(); - private final Button cancelButton = new Button(); - - private int stepIndex = 0; - - private AssetCreationWizard( - Window owner, - ProjectReference projectReference, - AssetCreationService assetCreationService) { - this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); - this.assetCreationService = Objects.requireNonNull(assetCreationService, "assetCreationService"); - this.stage = new Stage(); - stage.initOwner(owner); - stage.initModality(Modality.WINDOW_MODAL); - stage.setTitle(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_TITLE)); - stage.setScene(new Scene(buildRoot(), 620, 460)); - stage.getScene().getStylesheets().add(Container.theme().getDefaultTheme()); - - configureControls(); - renderStep(); - } - - public static Optional showAndWait( - Window owner, - ProjectReference projectReference, - AssetCreationService assetCreationService) { - final AssetCreationWizard wizard = new AssetCreationWizard(owner, projectReference, assetCreationService); - wizard.stage.showAndWait(); - return Optional.ofNullable(wizard.result.get()); - } - - private VBox buildRoot() { - stepTitle.getStyleClass().add("studio-launcher-section-title"); - stepDescription.getStyleClass().add("studio-launcher-subtitle"); - stepDescription.setWrapText(true); - feedbackLabel.getStyleClass().add("studio-launcher-feedback"); - feedbackLabel.setWrapText(true); - - backButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BACK)); - backButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); - backButton.setOnAction(ignored -> goBack()); - - nextButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_NEXT)); - nextButton.getStyleClass().addAll("studio-button", "studio-button-primary"); - nextButton.setOnAction(ignored -> goNext()); - - createButton.textProperty().bind(Container.i18n().bind(I18n.ASSETS_ADD_WIZARD_BUTTON_CREATE)); - createButton.getStyleClass().addAll("studio-button", "studio-button-primary"); - createButton.setOnAction(ignored -> createAsset()); - - cancelButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_CANCEL)); - cancelButton.getStyleClass().addAll("studio-button", "studio-button-cancel"); - cancelButton.setOnAction(ignored -> stage.close()); - - final Region spacer = new Region(); - HBox.setHgrow(spacer, Priority.ALWAYS); - final HBox actions = new HBox(12, backButton, spacer, cancelButton, nextButton, createButton); - actions.setAlignment(Pos.CENTER_RIGHT); - - final VBox root = new VBox(16, stepTitle, stepDescription, stepBody, feedbackLabel, actions); - root.setPadding(new Insets(24)); - VBox.setVgrow(stepBody, Priority.ALWAYS); - return root; - } - - private void configureControls() { - rootField.setPromptText("ui/new_asset"); - nameField.setPromptText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_NAME)); - typeCombo.setPromptText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_PROMPT_TYPE)); - formatCombo.setPromptText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_PROMPT_FORMAT)); - codecCombo.setPromptText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_PROMPT_CODEC)); - typeCombo.setMaxWidth(Double.MAX_VALUE); - formatCombo.setMaxWidth(Double.MAX_VALUE); - codecCombo.setMaxWidth(Double.MAX_VALUE); - typeCombo.setItems(FXCollections.observableArrayList(AssetCreationCatalog.assetTypes())); - formatCombo.setDisable(true); - codecCombo.setDisable(true); - preloadField.setSelected(true); - preloadField.textProperty().bind(Container.i18n().bind(I18n.ASSETS_ADD_WIZARD_LABEL_PRELOAD)); - - typeCombo.valueProperty().addListener((ignored, oldValue, newValue) -> { - if (Objects.equals(oldValue, newValue)) { - return; - } - formatCombo.setItems(FXCollections.observableArrayList()); - formatCombo.getSelectionModel().clearSelection(); - codecCombo.setItems(FXCollections.observableArrayList()); - codecCombo.getSelectionModel().clearSelection(); - codecCombo.setDisable(true); - if (newValue == null || newValue.isBlank()) { - formatCombo.setDisable(true); - return; - } - formatCombo.setItems(FXCollections.observableArrayList(AssetCreationCatalog.outputFormatsFor(newValue))); - formatCombo.setDisable(false); - }); - - formatCombo.valueProperty().addListener((ignored, oldValue, newValue) -> { - if (Objects.equals(oldValue, newValue)) { - return; - } - codecCombo.setItems(FXCollections.observableArrayList()); - codecCombo.getSelectionModel().clearSelection(); - if (newValue == null || newValue.isBlank() || typeCombo.getValue() == null || typeCombo.getValue().isBlank()) { - codecCombo.setDisable(true); - return; - } - codecCombo.setItems(FXCollections.observableArrayList(AssetCreationCatalog.outputCodecsFor(typeCombo.getValue(), newValue))); - codecCombo.setDisable(false); - }); - } - - private void renderStep() { - feedbackLabel.setText(""); - backButton.setDisable(stepIndex == 0); - nextButton.setVisible(stepIndex < 2); - nextButton.setManaged(stepIndex < 2); - createButton.setVisible(stepIndex == 2); - createButton.setManaged(stepIndex == 2); - - switch (stepIndex) { - case 0 -> renderRootStep(); - case 1 -> renderMetadataStep(); - case 2 -> renderSummaryStep(); - default -> throw new IllegalStateException("Unknown asset creation step: " + stepIndex); - } - } - - private void renderRootStep() { - stepTitle.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_STEP_ROOT_TITLE)); - stepDescription.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_STEP_ROOT_DESCRIPTION)); - - final Label assetsRootLabel = new Label(Container.i18n().format( - I18n.ASSETS_ADD_WIZARD_ASSETS_ROOT_HINT, - AssetRootValidator.assetsRoot(projectReference).toString())); - assetsRootLabel.getStyleClass().add("studio-launcher-subtitle"); - assetsRootLabel.setWrapText(true); - - final Button browseButton = new Button(Container.i18n().text(I18n.WIZARD_BROWSE)); - browseButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); - browseButton.setOnAction(ignored -> browseForRoot()); - - final HBox row = new HBox(12, rootField, browseButton); - row.setAlignment(Pos.CENTER_LEFT); - HBox.setHgrow(rootField, Priority.ALWAYS); - - stepBody.getChildren().setAll( - field(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_ROOT), row), - assetsRootLabel); - } - - private void renderMetadataStep() { - stepTitle.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_STEP_DETAILS_TITLE)); - stepDescription.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_STEP_DETAILS_DESCRIPTION)); - - stepBody.getChildren().setAll( - field(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_NAME), nameField), - field(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_TYPE), typeCombo), - field(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_FORMAT), formatCombo), - field(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_CODEC), codecCombo)); - } - - private void renderSummaryStep() { - stepTitle.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_STEP_SUMMARY_TITLE)); - stepDescription.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_STEP_SUMMARY_DESCRIPTION)); - - final AssetRootValidationResult rootValidation = AssetRootValidator.validate(projectReference, rootField.getText().trim()); - final VBox summary = new VBox(8); - summary.getChildren().addAll( - summaryRow(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_ROOT), rootValidation.normalizedRelativeRoot()), - summaryRow(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_NAME), nameField.getText().trim()), - summaryRow(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_TYPE), Objects.requireNonNullElse(typeCombo.getValue(), "—")), - summaryRow(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_FORMAT), Objects.requireNonNullElse(formatCombo.getValue(), "—")), - summaryRow(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_CODEC), Objects.requireNonNullElse(codecCombo.getValue(), "—"))); - - final Label note = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_NOTE)); - note.getStyleClass().add("studio-launcher-subtitle"); - note.setWrapText(true); - - stepBody.getChildren().setAll(summary, preloadField, note); - } - - private VBox field(String labelText, Node control) { - final Label label = new Label(labelText); - final VBox box = new VBox(6, label, control); - VBox.setVgrow(control, Priority.NEVER); - return box; - } - - private Node summaryRow(String key, String value) { - final HBox row = new HBox(12); - final Label keyLabel = new Label(key); - keyLabel.getStyleClass().add("assets-details-key"); - final Label valueLabel = new Label(value == null || value.isBlank() ? "—" : value); - valueLabel.getStyleClass().add("assets-details-value"); - valueLabel.setWrapText(true); - HBox.setHgrow(valueLabel, Priority.ALWAYS); - row.getChildren().addAll(keyLabel, valueLabel); - return row; - } - - private void goBack() { - if (stepIndex == 0) { - return; - } - stepIndex -= 1; - renderStep(); - } - - private void goNext() { - if (!validateCurrentStep()) { - return; - } - stepIndex += 1; - renderStep(); - } - - private boolean validateCurrentStep() { - return switch (stepIndex) { - case 0 -> validateRootStep(); - case 1 -> validateMetadataStep(); - default -> true; - }; - } - - private boolean validateRootStep() { - final AssetRootValidationResult validation = AssetRootValidator.validate(projectReference, rootField.getText().trim()); - if (!validation.valid()) { - feedbackLabel.setText(validation.message().isBlank() - ? Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_ROOT) - : validation.message()); - return false; - } - rootField.setText(validation.normalizedRelativeRoot()); - return true; - } - - private boolean validateMetadataStep() { - if (nameField.getText().trim().isBlank()) { - feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_NAME)); - return false; - } - if (typeCombo.getValue() == null || typeCombo.getValue().isBlank()) { - feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_TYPE)); - return false; - } - if (formatCombo.getValue() == null || formatCombo.getValue().isBlank()) { - feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_FORMAT)); - return false; - } - if (codecCombo.getValue() == null || codecCombo.getValue().isBlank()) { - feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_CODEC)); - return false; - } - if (!AssetCreationCatalog.supports(typeCombo.getValue(), formatCombo.getValue(), codecCombo.getValue())) { - feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_UNSUPPORTED_COMBINATION)); - return false; - } - return true; - } - - private void createAsset() { - feedbackLabel.setText(""); - if (!validateRootStep() || !validateMetadataStep()) { - return; - } - - try { - result.set(assetCreationService.create(projectReference, new AssetCreationRequest( - nameField.getText().trim(), - rootField.getText().trim(), - typeCombo.getValue(), - formatCombo.getValue(), - codecCombo.getValue(), - preloadField.isSelected()))); - stage.close(); - } catch (RuntimeException runtimeException) { - feedbackLabel.setText(Objects.requireNonNullElse(runtimeException.getMessage(), "Unable to create asset.")); - } - } - - private void browseForRoot() { - final DirectoryChooser chooser = new DirectoryChooser(); - chooser.setTitle(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_BROWSE_TITLE)); - final File initialDirectory = AssetRootValidator.assetsRoot(projectReference).toFile(); - if (initialDirectory.isDirectory()) { - chooser.setInitialDirectory(initialDirectory); - } else if (projectReference.rootPath().toFile().isDirectory()) { - chooser.setInitialDirectory(projectReference.rootPath().toFile()); - } - final File selected = chooser.showDialog(stage); - if (selected == null) { - return; - } - - final AssetRootValidationResult validation = AssetRootValidator.fromAbsoluteDirectory(projectReference, selected.toPath()); - if (!validation.valid()) { - feedbackLabel.setText(validation.message()); - return; - } - rootField.setText(validation.normalizedRelativeRoot()); - feedbackLabel.setText(""); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetLogsPane.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetLogsPane.java new file mode 100644 index 00000000..014d7556 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetLogsPane.java @@ -0,0 +1,45 @@ +package p.studio.workspaces.assets; + +import p.studio.Container; +import p.studio.events.StudioPackerOperationEvent; +import p.studio.events.StudioWorkspaceEventBus; +import p.studio.utilities.i18n.I18n; +import p.studio.utilities.logspane.LogsPane; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshFailedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshStartedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshedEvent; +import p.studio.workspaces.framework.StudioEventAware; +import p.studio.workspaces.framework.StudioEventBindings; + +import java.util.Objects; + +public final class AssetLogsPane extends LogsPane implements StudioEventAware { + private final StudioWorkspaceEventBus workspaceBus; + private final StudioEventBindings eventBindings = new StudioEventBindings(); + + public AssetLogsPane(StudioWorkspaceEventBus workspaceBus) { + super(Container.i18n().bind(I18n.ASSETS_LOGS_TITLE), "assets", false); + this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); + } + + @Override + public StudioEventBindings eventBindings() { + return eventBindings; + } + + @Override + public void registerEventSubscriptions() { + eventBindings.listen(workspaceBus, StudioAssetsWorkspaceRefreshStartedEvent.class).handle(event -> { + appendLine("[refresh] Asset scan started."); + }); + eventBindings.listen(workspaceBus, StudioAssetsWorkspaceRefreshedEvent.class).handle(event -> { + appendLine("[refresh] " + event.assetCount() + " assets loaded."); + }); + eventBindings.listen(workspaceBus, StudioAssetsWorkspaceRefreshFailedEvent.class).handle(event -> { + appendLine("[error] " + event.message()); + }); + eventBindings.listen(Container.eventBus(), StudioPackerOperationEvent.class).handle(event -> { + appendLine("[" + event.kind().name().toLowerCase() + "] " + event.summary()); + }); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorFilter.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorFilter.java deleted file mode 100644 index 729c96de..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorFilter.java +++ /dev/null @@ -1,8 +0,0 @@ -package p.studio.workspaces.assets; - -public enum AssetNavigatorFilter { - REGISTERED, - UNREGISTERED, - DIAGNOSTICS, - PRELOAD -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRelocationTargetValidator.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRelocationTargetValidator.java deleted file mode 100644 index b9e89eb6..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRelocationTargetValidator.java +++ /dev/null @@ -1,113 +0,0 @@ -package p.studio.workspaces.assets; - -import p.studio.projects.ProjectReference; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Objects; - -final class AssetRelocationTargetValidator { - private static final String CONTROL_DIR = ".prometeu"; - - private AssetRelocationTargetValidator() { - } - - static AssetRootValidationResult validate( - ProjectReference projectReference, - Path currentAssetRoot, - String parentRelativeRootText, - String destinationName) { - final String normalizedName = normalizeDestinationName(destinationName); - if (normalizedName == null) { - return AssetRootValidationResult.failure("Destination folder name is required."); - } - - final Path parentRelativeRoot; - try { - parentRelativeRoot = normalizeParentRelativeRoot(parentRelativeRootText); - } catch (RuntimeException runtimeException) { - return AssetRootValidationResult.failure("Destination parent must stay inside assets/."); - } - - final String relativeRootText = parentRelativeRoot.getNameCount() == 0 - ? normalizedName - : parentRelativeRoot.resolve(normalizedName).toString().replace('\\', '/'); - return validate(projectReference, currentAssetRoot, AssetRootValidator.assetsRoot(projectReference).resolve(relativeRootText)); - } - - static AssetRootValidationResult validate( - ProjectReference projectReference, - Path currentAssetRoot, - Path targetRoot) { - Objects.requireNonNull(projectReference, "projectReference"); - Objects.requireNonNull(currentAssetRoot, "currentAssetRoot"); - if (targetRoot == null) { - return AssetRootValidationResult.failure("Relocation target is required."); - } - - final Path assetsRoot = AssetRootValidator.assetsRoot(projectReference); - final Path normalizedTargetRoot = targetRoot.toAbsolutePath().normalize(); - if (!normalizedTargetRoot.startsWith(assetsRoot)) { - return AssetRootValidationResult.failure("Relocation target must stay inside assets/."); - } - - final Path relativeRoot = assetsRoot.relativize(normalizedTargetRoot); - if (relativeRoot.getNameCount() == 0) { - return AssetRootValidationResult.failure("Relocation target must point to an asset root inside assets/."); - } - if (CONTROL_DIR.equals(relativeRoot.getName(0).toString())) { - return AssetRootValidationResult.failure("Relocation target must not use the reserved .prometeu directory."); - } - - final Path normalizedCurrentRoot = currentAssetRoot.toAbsolutePath().normalize(); - if (normalizedTargetRoot.equals(normalizedCurrentRoot)) { - return AssetRootValidationResult.failure("Relocation target must be different from the current asset root."); - } - if (normalizedTargetRoot.startsWith(normalizedCurrentRoot)) { - return AssetRootValidationResult.failure("Relocation target must not be inside the current asset root."); - } - if (Files.exists(normalizedTargetRoot)) { - return AssetRootValidationResult.failure("Relocation target already exists: " - + relativeRoot.toString().replace('\\', '/')); - } - - return AssetRootValidationResult.success(relativeRoot.toString().replace('\\', '/'), normalizedTargetRoot); - } - - static String normalizeParentForDisplay(Path assetsRoot, Path parentDirectory) { - final Path normalizedAssetsRoot = Objects.requireNonNull(assetsRoot, "assetsRoot").toAbsolutePath().normalize(); - final Path normalizedParent = Objects.requireNonNull(parentDirectory, "parentDirectory").toAbsolutePath().normalize(); - if (!normalizedParent.startsWith(normalizedAssetsRoot)) { - throw new IllegalArgumentException("Parent directory must stay inside assets/"); - } - final Path relativeParent = normalizedAssetsRoot.relativize(normalizedParent); - return relativeParent.getNameCount() == 0 ? "." : relativeParent.toString().replace('\\', '/'); - } - - private static Path normalizeParentRelativeRoot(String parentRelativeRootText) { - final String trimmed = Objects.requireNonNullElse(parentRelativeRootText, "").trim(); - if (trimmed.isBlank() || ".".equals(trimmed)) { - return Path.of(""); - } - final Path relativeRoot = Path.of(trimmed).normalize(); - if (relativeRoot.isAbsolute() || relativeRoot.startsWith("..")) { - throw new IllegalArgumentException("Parent directory must stay inside assets/"); - } - if (relativeRoot.getNameCount() > 0 && CONTROL_DIR.equals(relativeRoot.getName(0).toString())) { - throw new IllegalArgumentException("Reserved directory"); - } - return relativeRoot; - } - - private static String normalizeDestinationName(String destinationName) { - final String trimmed = Objects.requireNonNullElse(destinationName, "").trim(); - if (trimmed.isBlank()) { - return null; - } - final Path segment = Path.of(trimmed).normalize(); - if (segment.isAbsolute() || segment.getNameCount() != 1 || ".".equals(trimmed) || "..".equals(trimmed)) { - return null; - } - return segment.getFileName().toString(); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRelocationWizard.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRelocationWizard.java deleted file mode 100644 index 7a4223b2..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRelocationWizard.java +++ /dev/null @@ -1,417 +0,0 @@ -package p.studio.workspaces.assets; - -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.scene.Node; -import javafx.scene.Scene; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.control.TextField; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.Region; -import javafx.scene.layout.VBox; -import javafx.stage.DirectoryChooser; -import javafx.stage.Modality; -import javafx.stage.Stage; -import javafx.stage.Window; -import p.studio.Container; -import p.studio.projects.ProjectReference; -import p.studio.utilities.i18n.I18n; - -import java.io.File; -import java.nio.file.Path; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicReference; - -public final class AssetRelocationWizard { - private final ProjectReference projectReference; - private final AssetWorkspaceAssetSummary asset; - private final AssetWorkspaceMutationService mutationService; - private final Stage stage; - private final AtomicReference result = new AtomicReference<>(); - - private final Label stepTitle = new Label(); - private final Label stepDescription = new Label(); - private final VBox stepBody = new VBox(12); - private final TextField parentField = new TextField(); - private final TextField nameField = new TextField(); - private final Label targetRootValue = new Label("—"); - private final Label feedbackLabel = new Label(); - private final Button backButton = new Button(); - private final Button nextButton = new Button(); - private final Button confirmButton = new Button(); - private final Button cancelButton = new Button(); - - private AssetWorkspaceMutationPreview preview; - private int stepIndex = 0; - - private AssetRelocationWizard( - Window owner, - ProjectReference projectReference, - AssetWorkspaceAssetSummary asset, - AssetWorkspaceMutationService mutationService) { - this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); - this.asset = Objects.requireNonNull(asset, "asset"); - this.mutationService = Objects.requireNonNull(mutationService, "mutationService"); - this.stage = new Stage(); - stage.initOwner(owner); - stage.initModality(Modality.WINDOW_MODAL); - stage.setTitle(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_TITLE)); - stage.setScene(new Scene(buildRoot(), 640, 440)); - stage.getScene().getStylesheets().add(Container.theme().getDefaultTheme()); - - configureDefaults(); - configureValidation(); - renderStep(); - } - - public static Optional showAndWait( - Window owner, - ProjectReference projectReference, - AssetWorkspaceAssetSummary asset, - AssetWorkspaceMutationService mutationService) { - final AssetRelocationWizard wizard = new AssetRelocationWizard(owner, projectReference, asset, mutationService); - wizard.stage.showAndWait(); - return Optional.ofNullable(wizard.result.get()); - } - - private VBox buildRoot() { - stepTitle.getStyleClass().add("studio-launcher-section-title"); - stepDescription.getStyleClass().add("studio-launcher-subtitle"); - stepDescription.setWrapText(true); - - feedbackLabel.getStyleClass().add("studio-launcher-feedback"); - feedbackLabel.setWrapText(true); - - backButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BACK)); - backButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); - backButton.setOnAction(ignored -> goBack()); - - nextButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_NEXT)); - nextButton.getStyleClass().addAll("studio-button", "studio-button-primary"); - nextButton.setOnAction(ignored -> goNext()); - - confirmButton.textProperty().bind(Container.i18n().bind(I18n.ASSETS_RELOCATE_WIZARD_BUTTON_CONFIRM)); - confirmButton.getStyleClass().addAll("studio-button", "studio-button-primary"); - confirmButton.setOnAction(ignored -> confirm()); - - cancelButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_CANCEL)); - cancelButton.getStyleClass().addAll("studio-button", "studio-button-cancel"); - cancelButton.setOnAction(ignored -> stage.close()); - - final Region spacer = new Region(); - HBox.setHgrow(spacer, Priority.ALWAYS); - final HBox actions = new HBox(12, backButton, spacer, cancelButton, nextButton, confirmButton); - actions.setAlignment(Pos.CENTER_RIGHT); - - final VBox root = new VBox(16, stepTitle, stepDescription, stepBody, feedbackLabel, actions); - root.setPadding(new Insets(24)); - VBox.setVgrow(stepBody, Priority.ALWAYS); - return root; - } - - private Node field(String labelText, Node control) { - final Label label = new Label(labelText); - final VBox box = new VBox(6, label, control); - VBox.setVgrow(control, Priority.NEVER); - return box; - } - - private void configureDefaults() { - final Path assetsRoot = AssetRootValidator.assetsRoot(projectReference); - final Path currentRoot = asset.assetRoot().toAbsolutePath().normalize(); - final Path currentParent = currentRoot.getParent() == null ? assetsRoot : currentRoot.getParent(); - parentField.setText(AssetRelocationTargetValidator.normalizeParentForDisplay(assetsRoot, currentParent)); - nameField.setPromptText(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_PROMPT_DESTINATION_NAME)); - final String currentLeaf = currentRoot.getFileName() == null ? "" : currentRoot.getFileName().toString(); - nameField.setText(currentLeaf); - } - - private void configureValidation() { - parentField.textProperty().addListener((ignored, oldValue, newValue) -> refreshValidation()); - nameField.textProperty().addListener((ignored, oldValue, newValue) -> refreshValidation()); - } - - private void renderStep() { - feedbackLabel.setText(""); - backButton.setDisable(stepIndex == 0); - nextButton.setVisible(stepIndex == 0); - nextButton.setManaged(stepIndex == 0); - confirmButton.setVisible(stepIndex == 1); - confirmButton.setManaged(stepIndex == 1); - - switch (stepIndex) { - case 0 -> renderDestinationStep(); - case 1 -> renderSummaryStep(); - default -> throw new IllegalStateException("Unknown relocation step: " + stepIndex); - } - } - - private void renderDestinationStep() { - stepTitle.setText(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_STEP_DESTINATION_TITLE)); - stepDescription.setText(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_STEP_DESTINATION_DESCRIPTION)); - - final Button browseButton = new Button(); - browseButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BROWSE)); - browseButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); - browseButton.setOnAction(ignored -> browseForParent()); - - final HBox parentRow = new HBox(12, parentField, browseButton); - parentRow.setAlignment(Pos.CENTER_LEFT); - HBox.setHgrow(parentField, Priority.ALWAYS); - - final Label currentRootValue = new Label(relativeAssetPath(asset.assetRoot())); - currentRootValue.getStyleClass().add("assets-details-value"); - currentRootValue.setWrapText(true); - - targetRootValue.getStyleClass().add("assets-details-value"); - targetRootValue.setWrapText(true); - - stepBody.getChildren().setAll( - field(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_LABEL_CURRENT_ROOT), currentRootValue), - field(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_LABEL_DESTINATION_PARENT), parentRow), - field(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_LABEL_DESTINATION_NAME), nameField), - field(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_LABEL_TARGET_ROOT), targetRootValue)); - refreshValidation(); - } - - private void renderSummaryStep() { - stepTitle.setText(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_STEP_SUMMARY_TITLE)); - stepDescription.setText(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_STEP_SUMMARY_DESCRIPTION)); - - final AssetWorkspaceMutationPreview currentPreview = Objects.requireNonNull(preview, "preview"); - final Label note = new Label(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_NOTE)); - note.getStyleClass().add("studio-launcher-subtitle"); - note.setWrapText(true); - - final VBox summary = new VBox(10); - summary.getChildren().addAll( - createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_AFFECTED_ASSET), - createAffectedAssetContent(currentPreview)), - createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_REGISTRY_IMPACT), - createMutationChangesContent( - AssetWorkspaceMutationImpactViewModel.from(currentPreview).registryChanges(), - Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_REGISTRY_IMPACT))), - createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_WORKSPACE_IMPACT), - createMutationChangesContent( - AssetWorkspaceMutationImpactViewModel.from(currentPreview).workspaceChanges(), - Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_WORKSPACE_IMPACT))), - createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_BLOCKERS), - createMutationMessages( - currentPreview.blockers(), - "assets-mutation-message-blocker", - Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_BLOCKERS))), - createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_WARNINGS), - createMutationMessages( - currentPreview.warnings(), - "assets-mutation-message-warning", - Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_WARNINGS))), - createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_SAFE_FIXES), - createMutationMessages( - currentPreview.safeFixes(), - "assets-mutation-message-safe-fix", - Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_SAFE_FIXES))), - note); - stepBody.getChildren().setAll(summary); - confirmButton.setDisable(!currentPreview.canApply()); - } - - private void refreshValidation() { - final AssetRootValidationResult validation = currentValidation(); - if (!validation.valid()) { - targetRootValue.setText("—"); - feedbackLabel.setText(validation.message()); - nextButton.setDisable(true); - confirmButton.setDisable(true); - return; - } - targetRootValue.setText(validation.normalizedRelativeRoot()); - feedbackLabel.setText(""); - nextButton.setDisable(false); - confirmButton.setDisable(false); - } - - private void browseForParent() { - final DirectoryChooser chooser = new DirectoryChooser(); - chooser.setTitle(Container.i18n().text(I18n.ASSETS_RELOCATE_WIZARD_BROWSE_TITLE)); - final File initialDirectory = initialParentDirectory().toFile(); - if (initialDirectory.isDirectory()) { - chooser.setInitialDirectory(initialDirectory); - } - final File selected = chooser.showDialog(stage); - if (selected == null) { - return; - } - - final Path selectedDirectory = selected.toPath().toAbsolutePath().normalize(); - final Path assetsRoot = AssetRootValidator.assetsRoot(projectReference); - if (!selectedDirectory.startsWith(assetsRoot)) { - feedbackLabel.setText("Destination parent must stay inside assets/."); - return; - } - final Path relativeParent = assetsRoot.relativize(selectedDirectory); - if (relativeParent.getNameCount() > 0 && ".prometeu".equals(relativeParent.getName(0).toString())) { - feedbackLabel.setText("Destination parent must not use the reserved .prometeu directory."); - return; - } - - parentField.setText(AssetRelocationTargetValidator.normalizeParentForDisplay(assetsRoot, selectedDirectory)); - refreshValidation(); - } - - private Path initialParentDirectory() { - final Path assetsRoot = AssetRootValidator.assetsRoot(projectReference); - try { - final String parentText = parentField.getText(); - if (parentText == null || parentText.isBlank() || ".".equals(parentText.trim())) { - return assetsRoot; - } - final Path candidate = assetsRoot.resolve(parentField.getText().trim()).toAbsolutePath().normalize(); - return candidate.startsWith(assetsRoot) && candidate.toFile().isDirectory() ? candidate : assetsRoot; - } catch (RuntimeException runtimeException) { - return assetsRoot; - } - } - - private void goBack() { - if (stepIndex == 0) { - return; - } - preview = null; - stepIndex -= 1; - renderStep(); - } - - private void goNext() { - final AssetRootValidationResult validation = currentValidation(); - if (!validation.valid()) { - feedbackLabel.setText(validation.message()); - nextButton.setDisable(true); - return; - } - try { - preview = mutationService.preview(projectReference, asset, AssetWorkspaceAction.RELOCATE, validation.assetRoot()); - } catch (RuntimeException runtimeException) { - preview = null; - feedbackLabel.setText(Objects.requireNonNullElse(runtimeException.getMessage(), "Unable to preview relocation.")); - return; - } - stepIndex = 1; - renderStep(); - } - - private void confirm() { - final AssetWorkspaceMutationPreview currentPreview = preview; - if (currentPreview == null) { - feedbackLabel.setText("Relocation preview is required before confirmation."); - confirmButton.setDisable(true); - return; - } - if (!currentPreview.canApply()) { - feedbackLabel.setText(currentPreview.blockers().isEmpty() - ? "Relocation cannot be applied." - : currentPreview.blockers().getFirst()); - confirmButton.setDisable(true); - return; - } - try { - mutationService.apply(projectReference, currentPreview); - result.set(currentPreview); - stage.close(); - } catch (RuntimeException runtimeException) { - feedbackLabel.setText(Objects.requireNonNullElse(runtimeException.getMessage(), "Unable to apply relocation.")); - } - } - - private AssetRootValidationResult currentValidation() { - return AssetRelocationTargetValidator.validate( - projectReference, - asset.assetRoot(), - parentField.getText(), - nameField.getText()); - } - - private Node createMutationSection(String title, Node content) { - final VBox section = new VBox(6); - final Label label = new Label(title); - label.getStyleClass().add("assets-mutation-section-title"); - section.getChildren().addAll(label, content); - return section; - } - - private Node createAffectedAssetContent(AssetWorkspaceMutationPreview currentPreview) { - final VBox box = new VBox(6); - box.getChildren().add(keyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_NAME), currentPreview.asset().assetName())); - box.getChildren().add(keyValueRow( - Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), - relativeAssetPath(currentPreview.asset().assetRoot()))); - if (currentPreview.targetAssetRoot() != null) { - box.getChildren().add(keyValueRow( - Container.i18n().text(I18n.ASSETS_LABEL_TARGET_LOCATION), - relativeAssetPath(currentPreview.targetAssetRoot()))); - } - return box; - } - - private Node createMutationChangesContent(java.util.List changes, String emptyText) { - if (changes.isEmpty()) { - final Label label = new Label(emptyText); - label.setWrapText(true); - label.getStyleClass().add("assets-details-section-message"); - return label; - } - final VBox box = new VBox(6); - for (AssetWorkspaceMutationChange change : changes) { - final Label label = new Label(change.verb() + " · " + change.target()); - label.getStyleClass().add("assets-mutation-change"); - box.getChildren().add(label); - } - return box; - } - - private Node createMutationMessages(java.util.List messages, String styleClass, String emptyText) { - if (messages.isEmpty()) { - final Label label = new Label(emptyText); - label.setWrapText(true); - label.getStyleClass().add("assets-details-section-message"); - return label; - } - final VBox box = new VBox(6); - for (String message : messages) { - final Label label = new Label(message); - label.setWrapText(true); - label.getStyleClass().add(styleClass); - box.getChildren().add(label); - } - return box; - } - - private Node keyValueRow(String key, String value) { - final HBox row = new HBox(12); - final Label keyLabel = new Label(key); - keyLabel.getStyleClass().add("assets-details-key"); - final Label valueLabel = new Label(value == null || value.isBlank() ? "—" : value); - valueLabel.getStyleClass().add("assets-details-value"); - valueLabel.setWrapText(true); - HBox.setHgrow(valueLabel, Priority.ALWAYS); - row.getChildren().addAll(keyLabel, valueLabel); - return row; - } - - private String relativeAssetPath(Path assetRoot) { - final Path assetsRoot = AssetRootValidator.assetsRoot(projectReference); - final Path normalizedAssetRoot = assetRoot.toAbsolutePath().normalize(); - if (!normalizedAssetRoot.startsWith(assetsRoot)) { - return normalizedAssetRoot.toString(); - } - return assetsRoot.relativize(normalizedAssetRoot).toString().replace('\\', '/'); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRootValidationResult.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRootValidationResult.java deleted file mode 100644 index 9dabc923..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRootValidationResult.java +++ /dev/null @@ -1,25 +0,0 @@ -package p.studio.workspaces.assets; - -import java.nio.file.Path; -import java.util.Objects; - -public record AssetRootValidationResult( - boolean valid, - String normalizedRelativeRoot, - Path assetRoot, - String message) { - - public AssetRootValidationResult { - normalizedRelativeRoot = Objects.requireNonNullElse(normalizedRelativeRoot, ""); - message = Objects.requireNonNullElse(message, ""); - assetRoot = assetRoot == null ? null : assetRoot.toAbsolutePath().normalize(); - } - - public static AssetRootValidationResult success(String normalizedRelativeRoot, Path assetRoot) { - return new AssetRootValidationResult(true, normalizedRelativeRoot, assetRoot, ""); - } - - public static AssetRootValidationResult failure(String message) { - return new AssetRootValidationResult(false, "", null, message); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRootValidator.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRootValidator.java deleted file mode 100644 index 91eee3ef..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetRootValidator.java +++ /dev/null @@ -1,60 +0,0 @@ -package p.studio.workspaces.assets; - -import p.packer.api.PackerProjectContext; -import p.packer.foundation.PackerWorkspacePaths; -import p.studio.projects.ProjectReference; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Objects; - -final class AssetRootValidator { - private static final String ASSET_MANIFEST = "asset.json"; - private static final String CONTROL_DIR = ".prometeu"; - - private AssetRootValidator() { - } - - static Path assetsRoot(ProjectReference projectReference) { - final ProjectReference reference = Objects.requireNonNull(projectReference, "projectReference"); - return PackerWorkspacePaths.assetsRoot(new PackerProjectContext(reference.name(), reference.rootPath())); - } - - static AssetRootValidationResult validate(ProjectReference projectReference, String relativeRootText) { - final Path relativeRoot; - try { - relativeRoot = Path.of(Objects.requireNonNullElse(relativeRootText, "").trim()).normalize(); - } catch (RuntimeException runtimeException) { - return AssetRootValidationResult.failure("Asset root must be a valid relative path."); - } - - if (relativeRootText == null || relativeRootText.isBlank() || relativeRoot.getNameCount() == 0 || relativeRoot.isAbsolute() || relativeRoot.startsWith("..")) { - return AssetRootValidationResult.failure("Asset root must stay inside assets/."); - } - if (CONTROL_DIR.equals(relativeRoot.getName(0).toString())) { - return AssetRootValidationResult.failure("Asset root must not target the reserved .prometeu directory."); - } - - final Path assetsRoot = assetsRoot(projectReference); - final Path assetRoot = assetsRoot.resolve(relativeRoot).toAbsolutePath().normalize(); - if (!assetRoot.startsWith(assetsRoot)) { - return AssetRootValidationResult.failure("Asset root must stay inside assets/."); - } - if (Files.isRegularFile(assetRoot.resolve(ASSET_MANIFEST))) { - return AssetRootValidationResult.failure("asset.json already exists at: " + relativeRoot.toString().replace('\\', '/')); - } - return AssetRootValidationResult.success(relativeRoot.toString().replace('\\', '/'), assetRoot); - } - - static AssetRootValidationResult fromAbsoluteDirectory(ProjectReference projectReference, Path directory) { - if (directory == null) { - return AssetRootValidationResult.failure("Asset root is required."); - } - final Path assetsRoot = assetsRoot(projectReference); - final Path normalized = directory.toAbsolutePath().normalize(); - if (!normalized.startsWith(assetsRoot)) { - return AssetRootValidationResult.failure("Choose a directory inside assets/."); - } - return validate(projectReference, assetsRoot.relativize(normalized).toString().replace('\\', '/')); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java index 44e8e1d8..0cdb30c6 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java @@ -1,123 +1,44 @@ package p.studio.workspaces.assets; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import javafx.application.Platform; -import javafx.geometry.Insets; -import javafx.geometry.Orientation; import javafx.geometry.Pos; import javafx.scene.Node; -import javafx.scene.control.*; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; -import javafx.scene.layout.*; -import p.packer.api.PackerProjectContext; -import p.packer.api.building.PackerBuildRequest; -import p.packer.api.building.PackerBuildResult; -import p.packer.api.doctor.PackerDoctorMode; -import p.packer.api.doctor.PackerDoctorRequest; -import p.packer.api.doctor.PackerDoctorResult; -import p.packer.building.FileSystemPackerBuildService; -import p.packer.declarations.PackerAssetDeclarationParser; -import p.packer.doctor.FileSystemPackerDoctorService; -import p.packer.foundation.PackerWorkspaceFoundation; -import p.packer.workspace.FileSystemPackerWorkspaceService; +import javafx.scene.control.Button; +import javafx.scene.control.SplitPane; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; import p.studio.Container; -import p.studio.events.*; import p.studio.projects.ProjectReference; import p.studio.utilities.i18n.I18n; import p.studio.workspaces.Workspace; import p.studio.workspaces.WorkspaceId; +import p.studio.workspaces.assets.details.AssetDetailsControl; +import p.studio.workspaces.assets.list.AssetListControl; +import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceSelectionRequestedEvent; +import p.studio.workspaces.assets.wizards.AddAssetWizard; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; -import java.util.concurrent.CompletableFuture; - -public final class AssetWorkspace implements Workspace, AssetWorkspaceInteractionPort { - private static final ObjectMapper MAPPER = new ObjectMapper(); +import java.util.List; +public final class AssetWorkspace extends Workspace { private final BorderPane root = new BorderPane(); - private final ProjectReference projectReference; - private final AssetWorkspaceService assetWorkspaceService; - private final AssetWorkspaceMutationService mutationService; - private final AssetCreationService assetCreationService; - private final FileSystemPackerDoctorService doctorService; - private final FileSystemPackerBuildService packService; - private final StudioWorkspaceEventBus workspaceBus; + private final AssetListControl assetListControl; + private final AssetDetailsControl detailsControl; + private final AssetLogsPane logsPane; - private final Button addAssetButton = new Button(); - private final Button doctorButton = new Button(); - private final Button packButton = new Button(); - private final Label inlineProgressLabel = new Label(); - private final ProgressBar inlineProgressBar = new ProgressBar(); - private final TextArea logsArea = new TextArea(); - private final AssetWorkspaceNavigatorControl navigatorControl; - private final AssetWorkspaceDetailsControl detailsControl; - private final EnumSet activeFilters = EnumSet.noneOf(AssetNavigatorFilter.class); - - private volatile AssetWorkspaceState state = AssetWorkspaceState.loading(null); - private volatile AssetWorkspaceDetailsStatus detailsStatus = AssetWorkspaceDetailsStatus.EMPTY; - private volatile AssetWorkspaceAssetDetails selectedAssetDetails; - private volatile String detailsErrorMessage; - private volatile AssetWorkspaceMutationPreview stagedMutationPreview; - private volatile Path selectedPreviewInput; - private volatile int selectedPreviewZoom = 1; - private volatile AssetWorkspaceSelectionKey pendingSelectionKey; - private String searchQuery = ""; - - public AssetWorkspace(ProjectReference projectReference) { - this( - projectReference, - null, - defaultWorkspaceBus(), - null, - null); - } - - private AssetWorkspace( - ProjectReference projectReference, - AssetWorkspaceService assetWorkspaceService, - StudioWorkspaceEventBus workspaceBus, - AssetWorkspaceMutationService mutationService, - AssetCreationService assetCreationService) { - this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); - this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); - final StudioPackerEventAdapter packerEventAdapter = new StudioPackerEventAdapter(this.workspaceBus, this.projectReference); - this.assetWorkspaceService = assetWorkspaceService == null - ? new PackerBackedAssetWorkspaceService(new FileSystemPackerWorkspaceService( - new PackerWorkspaceFoundation(), - new PackerAssetDeclarationParser(), - packerEventAdapter)) - : Objects.requireNonNull(assetWorkspaceService, "assetWorkspaceService"); - this.mutationService = mutationService == null - ? new PackerBackedAssetWorkspaceMutationService(this.workspaceBus) - : Objects.requireNonNull(mutationService, "mutationService"); - this.assetCreationService = assetCreationService == null - ? new PackerBackedAssetCreationService() - : Objects.requireNonNull(assetCreationService, "assetCreationService"); - this.doctorService = new FileSystemPackerDoctorService( - new FileSystemPackerWorkspaceService( - new PackerWorkspaceFoundation(), - new PackerAssetDeclarationParser(), - packerEventAdapter), - new p.packer.declarations.PackerAssetDetailsService(), - packerEventAdapter); - this.packService = new FileSystemPackerBuildService(new p.packer.building.PackerBuildPlanner(), packerEventAdapter); - this.navigatorControl = new AssetWorkspaceNavigatorControl(this.projectReference, this.workspaceBus, this); - this.detailsControl = new AssetWorkspaceDetailsControl(this.projectReference, this.workspaceBus, this); - - subscribeLocalEvents(); + public AssetWorkspace(final ProjectReference projectReference) { + super(projectReference); + this.assetListControl = new AssetListControl(this.projectReference, this.workspaceEventBus); + this.detailsControl = new AssetDetailsControl(this.projectReference, this.workspaceEventBus); + this.logsPane = new AssetLogsPane(this.workspaceEventBus); root.getStyleClass().add("assets-workspace"); root.setCenter(buildLayout()); - root.setBottom(buildLogsPane()); - renderState(); + root.setBottom(logsPane.getPane()); } @Override - public WorkspaceId id() { + public WorkspaceId workspaceId() { return WorkspaceId.ASSETS; } @@ -127,820 +48,86 @@ public final class AssetWorkspace implements Workspace, AssetWorkspaceInteractio } @Override - public Node root() { + public Node rootNode() { return root; } @Override - public void onShow() { - refresh(); + public void load() { + workspaceEventBus.publish(new StudioAssetsRefreshRequestedEvent()); } - AssetWorkspaceState state() { - return state; + @Override + public void unLoad() { } - private static StudioWorkspaceEventBus defaultWorkspaceBus() { - return new StudioWorkspaceEventBus(WorkspaceId.ASSETS, Container.events()); - } - - private void subscribeLocalEvents() { - workspaceBus.subscribe(StudioAssetsWorkspaceSelectionRequestedEvent.class, event -> { - if (projectMatches(event.project())) { - applySelectionRequest(event.selectionKey()); - } - }); - workspaceBus.subscribe(StudioAssetsAssetSummaryPatchedEvent.class, event -> { - if (projectMatches(event.project())) { - applyAssetSummaryPatch(event.summary()); - } - }); - workspaceBus.subscribe(StudioAssetsStructuralSyncRequestedEvent.class, event -> { - if (projectMatches(event.project())) { - pendingSelectionKey = event.preferredSelectionKey(); - refresh(); - } - }); - } - - private boolean projectMatches(ProjectReference project) { - return projectReference.equals(project); + @Override + public List lifecycleParticipants() { + return List.of(assetListControl, detailsControl, logsPane); } private VBox buildLayout() { - inlineProgressLabel.getStyleClass().add("assets-workspace-inline-progress-label"); - inlineProgressLabel.setText(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE)); - inlineProgressBar.getStyleClass().add("assets-workspace-inline-progress-bar"); - inlineProgressBar.setVisible(false); - inlineProgressBar.setManaged(false); - final VBox topProgress = new VBox(6, inlineProgressLabel, inlineProgressBar); + final var topProgress = createTopProgressBox(); + final var actionBar = createActionBar(); + final var splitPane = createAssetSplitPane(); + return new VBox(10, topProgress, actionBar, splitPane); + } + + private VBox createTopProgressBox() { + final var topProgress = new VBox(6); topProgress.getStyleClass().add("assets-workspace-inline-progress"); - addAssetButton.textProperty().bind(Container.i18n().bind(I18n.ASSETS_NAVIGATOR_ACTION_ADD)); - addAssetButton.getStyleClass().addAll("studio-button", "studio-button-primary"); - addAssetButton.setMaxWidth(Double.MAX_VALUE); - addAssetButton.setOnAction(event -> openAddAssetWizard()); - doctorButton.textProperty().bind(Container.i18n().bind(I18n.ASSETS_NAVIGATOR_ACTION_DOCTOR)); - doctorButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); - doctorButton.setOnAction(event -> runDoctor()); - packButton.textProperty().bind(Container.i18n().bind(I18n.ASSETS_NAVIGATOR_ACTION_PACK)); - packButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); - packButton.setOnAction(event -> runPack()); - final SplitPane splitPane = new SplitPane(navigatorControl, detailsControl); - splitPane.setDividerPositions(0.34); - splitPane.getStyleClass().add("assets-workspace-split"); - final HBox workspaceActionBar = new HBox(8, addAssetButton, doctorButton, packButton); + return topProgress; + } + + private HBox createActionBar() { + final var refreshButton = createRefreshButton(); + final var addAssetButton = createAddAssetButton(); + final var workspaceActionBar = new HBox(8, refreshButton, addAssetButton); workspaceActionBar.setAlignment(Pos.CENTER_LEFT); workspaceActionBar.getStyleClass().add("assets-workspace-action-bar"); - final VBox layout = new VBox(10, topProgress, workspaceActionBar, splitPane); - VBox.setVgrow(splitPane, Priority.ALWAYS); - return layout; + return workspaceActionBar; } - private TitledPane buildLogsPane() { - logsArea.setEditable(false); - logsArea.setWrapText(true); - logsArea.setPrefRowCount(8); - logsArea.getStyleClass().add("assets-workspace-logs"); - - final TitledPane pane = new TitledPane(); - pane.textProperty().bind(Container.i18n().bind(I18n.ASSETS_LOGS_TITLE)); - pane.setContent(logsArea); - pane.setCollapsible(true); - pane.setExpanded(true); - pane.getStyleClass().add("assets-workspace-logs-pane"); - return pane; + private Button createRefreshButton() { + final var refreshButton = new Button(); + refreshButton.textProperty().bind(Container.i18n().bind(I18n.ASSETS_NAVIGATOR_ACTION_REFRESH)); + refreshButton.getStyleClass().addAll("studio-button", "studio-button-primary", "assets-workspace-action-button"); + refreshButton.setMaxWidth(Double.MAX_VALUE); + refreshButton.setOnAction(event -> refreshAssets()); + return refreshButton; } - private void refresh() { - final boolean preserveVisibleContent = hasVisibleWorkspaceContent(); - if (!preserveVisibleContent) { - state = AssetWorkspaceState.loading(state); - detailsStatus = AssetWorkspaceDetailsStatus.EMPTY; - selectedAssetDetails = null; - detailsErrorMessage = null; - stagedMutationPreview = null; - selectedPreviewInput = null; - selectedPreviewZoom = 1; - requestRedraw(); - } - setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_REFRESHING), ProgressBar.INDETERMINATE_PROGRESS, true); - appendLog("Assets refresh started."); - workspaceBus.publish(new StudioAssetsWorkspaceRefreshStartedEvent(projectReference)); - - CompletableFuture - .supplyAsync(() -> assetWorkspaceService.loadWorkspace(projectReference)) - .whenComplete((snapshot, throwable) -> Platform.runLater(() -> { - if (throwable != null) { - pendingSelectionKey = null; - if (!preserveVisibleContent) { - state = AssetWorkspaceState.error(state, rootCauseMessage(throwable)); - detailsStatus = AssetWorkspaceDetailsStatus.ERROR; - detailsErrorMessage = state.errorMessage(); - } - setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); - appendLog("Assets refresh failed: " + rootCauseMessage(throwable)); - requestRedraw(); - workspaceBus.publish(new StudioAssetsWorkspaceRefreshFailedEvent(projectReference, rootCauseMessage(throwable))); - return; - } - - final AssetWorkspaceSelectionKey preferredSelection = pendingSelectionKey != null - ? pendingSelectionKey - : state.selectedKey(); - state = AssetWorkspaceState.ready(snapshot.assets(), preferredSelection); - pendingSelectionKey = null; - setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); - appendLog("Assets refresh completed: " + state.assets().size() + " assets."); - requestRedraw(); - workspaceBus.publish(new StudioAssetsWorkspaceRefreshedEvent(projectReference, state.assets().size())); - state.selectedAsset().ifPresent(asset -> { - workspaceBus.publish(new StudioAssetsWorkspaceSelectionChangedEvent(projectReference, asset.selectionKey())); - loadSelectedAssetDetails(asset.selectionKey(), preserveVisibleContent); - }); - })); + private void refreshAssets() { + workspaceEventBus.publish(new StudioAssetsRefreshRequestedEvent()); } - private void loadSelectedAssetDetails(AssetWorkspaceSelectionKey selectionKey) { - loadSelectedAssetDetails(selectionKey, false); - } - - private void loadSelectedAssetDetails(AssetWorkspaceSelectionKey selectionKey, boolean preserveExistingContent) { - final boolean preserveDetails = preserveExistingContent - && selectedAssetDetails != null - && selectionKey.equals(selectedAssetDetails.summary().selectionKey()); - if (!preserveDetails) { - detailsStatus = AssetWorkspaceDetailsStatus.LOADING; - selectedAssetDetails = null; - detailsErrorMessage = null; - stagedMutationPreview = null; - selectedPreviewInput = null; - selectedPreviewZoom = 1; - requestDetailsRedraw(); - } - setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_LOADING_DETAILS), ProgressBar.INDETERMINATE_PROGRESS, true); - appendLog("Loading details for " + selectionKey.stableKey() + "."); - - CompletableFuture - .supplyAsync(() -> assetWorkspaceService.loadAssetDetails(projectReference, selectionKey)) - .whenComplete((details, throwable) -> Platform.runLater(() -> { - if (!selectionKey.equals(state.selectedKey())) { - return; - } - if (throwable != null) { - final String message = rootCauseMessage(throwable); - if (!preserveDetails) { - detailsStatus = AssetWorkspaceDetailsStatus.ERROR; - detailsErrorMessage = message; - selectedAssetDetails = null; - } - setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); - appendLog("Asset details failed: " + message); - requestDetailsRedraw(); - return; - } - - selectedAssetDetails = details; - detailsStatus = AssetWorkspaceDetailsStatus.READY; - selectedPreviewInput = firstPreviewInput(details); - selectedPreviewZoom = 1; - setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); - appendLog("Asset details ready for " + details.summary().assetName() + "."); - requestDetailsRedraw(); - })); - } - - private boolean hasVisibleWorkspaceContent() { - return !state.assets().isEmpty() && state.status() != AssetWorkspaceStatus.LOADING; + private Button createAddAssetButton() { + final var addAssetButton = new Button(); + addAssetButton.textProperty().bind(Container.i18n().bind(I18n.ASSETS_NAVIGATOR_ACTION_ADD)); + addAssetButton.getStyleClass().addAll( + "studio-button", + "studio-button-secondary", + "studio-button-add-asset", + "assets-workspace-action-button"); + addAssetButton.setOnAction(event -> openAddAssetWizard()); + return addAssetButton; } private void openAddAssetWizard() { - if (root.getScene() == null || root.getScene().getWindow() == null) { + if (root.getScene() == null) { return; } - AssetCreationWizard.showAndWait(root.getScene().getWindow(), projectReference, assetCreationService) - .ifPresent(result -> { - appendLog("Asset created: " + projectRelativePath(result.assetRoot()) + "."); - requestStructuralSync("asset created", result.selectionKey()); - }); + AddAssetWizard.showAndWait(root.getScene().getWindow(), projectReference).ifPresent(assetReference -> { + workspaceEventBus.publish(new StudioAssetsRefreshRequestedEvent(assetReference)); + workspaceEventBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(assetReference)); + }); } - private void renderState() { - publishNavigatorViewState(); - publishDetailsViewState(); - } - - private void requestRedraw() { - publishNavigatorViewState(); - publishDetailsViewState(); - } - - private void requestNavigatorRedraw() { - publishNavigatorViewState(); - } - - private void requestDetailsRedraw() { - publishDetailsViewState(); - } - - private void publishNavigatorViewState() { - final AssetNavigatorProjection projection = state.status() == AssetWorkspaceStatus.READY - ? AssetNavigatorProjectionBuilder.build(state.assets(), projectRoot(), searchQuery, activeFilters) - : null; - workspaceBus.publish(new StudioAssetsNavigatorViewStateChangedEvent( - projectReference, - new AssetWorkspaceNavigatorViewState(state, projection, navigatorMessage(projection)))); - } - - private void publishDetailsViewState() { - workspaceBus.publish(new StudioAssetsDetailsViewStateChangedEvent( - projectReference, - new AssetWorkspaceDetailsViewState( - state, - detailsStatus, - selectedAssetDetails, - detailsErrorMessage, - stagedMutationPreview, - selectedPreviewInput, - selectedPreviewZoom))); - } - - private String navigatorMessage(AssetNavigatorProjection projection) { - return switch (state.status()) { - case LOADING -> Container.i18n().text(I18n.ASSETS_STATE_LOADING); - case EMPTY -> Container.i18n().text(I18n.ASSETS_STATE_EMPTY); - case ERROR -> state.errorMessage(); - case READY -> projection == null || projection.isEmpty() - ? Container.i18n().text(I18n.ASSETS_STATE_NO_RESULTS) - : Container.i18n().format(I18n.ASSETS_STATE_READY, projection.visibleAssetCount(), state.assets().size()); - }; - } - - private String actionLabel(AssetWorkspaceAction action) { - return switch (action) { - case REGISTER -> Container.i18n().text(I18n.ASSETS_ACTION_REGISTER); - case INCLUDE_IN_BUILD -> Container.i18n().text(I18n.ASSETS_ACTION_INCLUDE_IN_BUILD); - case EXCLUDE_FROM_BUILD -> Container.i18n().text(I18n.ASSETS_ACTION_EXCLUDE_FROM_BUILD); - case RELOCATE -> Container.i18n().text(I18n.ASSETS_ACTION_RELOCATE); - case REMOVE -> Container.i18n().text(I18n.ASSETS_ACTION_REMOVE); - }; - } - - static void applyPreviewScale(Image image, ImageView imageView, int requestedZoom) { - final double width = image.getWidth(); - final double height = image.getHeight(); - if (width <= 0.0d || height <= 0.0d) { - imageView.setFitWidth(420); - return; - } - - final double scale = previewScale(width, height, requestedZoom); - imageView.setFitWidth(Math.max(1.0d, width * scale)); - imageView.setFitHeight(Math.max(1.0d, height * scale)); - imageView.setSmooth(false); - } - - static double previewScale(double width, double height, int requestedZoom) { - final double longestEdge = Math.max(width, height); - if (longestEdge <= 0.0d) { - return 1.0d; - } - if (longestEdge > 420.0d) { - return 420.0d / longestEdge; - } - return Math.max(1, Math.min(Math.max(1, requestedZoom), maxPreviewZoom(width, height))); - } - - static int maxPreviewZoom(Image image) { - return maxPreviewZoom(image.getWidth(), image.getHeight()); - } - - static int maxPreviewZoom(double width, double height) { - final double longestEdge = Math.max(width, height); - if (longestEdge <= 0.0d || longestEdge > 420.0d) { - return 1; - } - return Math.max(1, (int) Math.floor(420.0d / longestEdge)); - } - - @Override - public void updatePreload(AssetWorkspaceAssetDetails details, boolean preloadEnabled, CheckBox checkBox) { - checkBox.setDisable(true); - setInlineProgress("Updating preload...", ProgressBar.INDETERMINATE_PROGRESS, true); - appendLog("Updating preload for " + details.summary().assetName() + " to " + yesNo(preloadEnabled) + "."); - CompletableFuture - .runAsync(() -> writePreloadFlag(details.summary().assetRoot(), preloadEnabled)) - .whenComplete((ignored, throwable) -> Platform.runLater(() -> { - checkBox.setDisable(false); - if (throwable != null) { - final boolean previousValue = !preloadEnabled; - checkBox.setSelected(previousValue); - checkBox.setText(yesNo(previousValue)); - setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); - appendLog("Preload update failed: " + rootCauseMessage(throwable)); - return; - } - - final AssetWorkspaceAssetSummary updatedSummary = withPreload(details.summary(), preloadEnabled); - setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); - appendLog("Preload updated for " + updatedSummary.assetName() + "."); - workspaceBus.publish(new StudioAssetsAssetSummaryPatchedEvent(projectReference, updatedSummary)); - })); - } - - private void writePreloadFlag(Path assetRoot, boolean preloadEnabled) { - final Path manifestPath = assetRoot.resolve("asset.json"); - try { - final JsonNode rootNode = MAPPER.readTree(manifestPath.toFile()); - if (!(rootNode instanceof ObjectNode rootObject)) { - throw new IllegalStateException("asset.json root is not an object"); - } - final JsonNode preloadNode = rootObject.path("preload"); - final ObjectNode preloadObject = preloadNode instanceof ObjectNode objectNode - ? objectNode - : rootObject.putObject("preload"); - preloadObject.put("enabled", preloadEnabled); - MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), rootObject); - } catch (IOException ioException) { - throw new IllegalStateException("Unable to update preload flag: " + ioException.getMessage(), ioException); - } - } - - private AssetWorkspaceAssetSummary withPreload(AssetWorkspaceAssetSummary summary, boolean preloadEnabled) { - return new AssetWorkspaceAssetSummary( - summary.selectionKey(), - summary.assetName(), - summary.state(), - summary.buildParticipation(), - summary.assetId(), - summary.assetFamily(), - summary.assetRoot(), - preloadEnabled, - summary.hasDiagnostics()); - } - - private AssetWorkspaceAssetSummary withBuildParticipation( - AssetWorkspaceAssetSummary summary, - AssetWorkspaceBuildParticipation buildParticipation) { - return new AssetWorkspaceAssetSummary( - summary.selectionKey(), - summary.assetName(), - summary.state(), - buildParticipation, - summary.assetId(), - summary.assetFamily(), - summary.assetRoot(), - summary.preload(), - summary.hasDiagnostics()); - } - - private List replaceAssetSummary(AssetWorkspaceAssetSummary updatedSummary) { - return state.assets().stream() - .map(asset -> asset.selectionKey().equals(updatedSummary.selectionKey()) ? updatedSummary : asset) - .toList(); - } - - private void applyAssetSummaryPatch(AssetWorkspaceAssetSummary updatedSummary) { - state = AssetWorkspaceState.ready(replaceAssetSummary(updatedSummary), state.selectedKey()); - if (selectedAssetDetails != null && selectedAssetDetails.summary().selectionKey().equals(updatedSummary.selectionKey())) { - selectedAssetDetails = new AssetWorkspaceAssetDetails( - updatedSummary, - selectedAssetDetails.outputFormat(), - selectedAssetDetails.outputCodec(), - selectedAssetDetails.inputsByRole(), - selectedAssetDetails.diagnostics()); - } - requestRedraw(); - } - - @Override - public void selectAsset(AssetWorkspaceSelectionKey selectionKey) { - workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(projectReference, selectionKey)); - } - - private void applySelectionRequest(AssetWorkspaceSelectionKey selectionKey) { - state = state.withSelection(selectionKey); - stagedMutationPreview = null; - appendLog("Selected asset " + selectionKey.stableKey() + "."); - requestRedraw(); - workspaceBus.publish(new StudioAssetsWorkspaceSelectionChangedEvent(projectReference, selectionKey)); - loadSelectedAssetDetails(selectionKey); - } - - private boolean supportsAction(AssetWorkspaceAction action) { - return action == AssetWorkspaceAction.REGISTER - || action == AssetWorkspaceAction.INCLUDE_IN_BUILD - || action == AssetWorkspaceAction.EXCLUDE_FROM_BUILD - || action == AssetWorkspaceAction.RELOCATE - || action == AssetWorkspaceAction.REMOVE; - } - - private void runDoctor() { - setNavigatorActionsDisabled(true); - setInlineProgress("Running doctor...", ProgressBar.INDETERMINATE_PROGRESS, true); - appendLog("Doctor started."); - CompletableFuture - .supplyAsync(() -> doctorService.doctor(new PackerDoctorRequest(projectContext(), PackerDoctorMode.EXPANDED_WORKSPACE, true))) - .whenComplete((result, throwable) -> Platform.runLater(() -> { - setNavigatorActionsDisabled(false); - if (throwable != null) { - setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); - appendLog("Doctor failed: " + rootCauseMessage(throwable)); - return; - } - handleDoctorResult(result); - })); - } - - private void runPack() { - setNavigatorActionsDisabled(true); - setInlineProgress("Packing assets...", ProgressBar.INDETERMINATE_PROGRESS, true); - appendLog("Pack started."); - CompletableFuture - .supplyAsync(() -> packService.build(new PackerBuildRequest(projectContext(), false))) - .whenComplete((result, throwable) -> Platform.runLater(() -> { - setNavigatorActionsDisabled(false); - if (throwable != null) { - setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); - appendLog("Pack failed: " + rootCauseMessage(throwable)); - return; - } - handlePackResult(result); - })); - } - - private void handleDoctorResult(PackerDoctorResult result) { - setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); - appendLog(result.summary()); - if (!result.safeFixes().isEmpty()) { - result.safeFixes().forEach(fix -> appendLog("Safe fix: " + fix)); - } - if (!result.diagnostics().isEmpty()) { - appendLog("Doctor diagnostics: " + result.diagnostics().size() + "."); - } - } - - private void handlePackResult(PackerBuildResult result) { - setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_IDLE), 0, false); - appendLog(result.summary()); - appendLog("Assets archive: " + projectRelativePath(result.assetsArchive())); - if (!result.companionArtifacts().isEmpty()) { - result.companionArtifacts().keySet().stream().sorted().forEach(path -> appendLog("Artifact: " + path)); - } - } - - private void setNavigatorActionsDisabled(boolean disabled) { - addAssetButton.setDisable(disabled); - doctorButton.setDisable(disabled); - packButton.setDisable(disabled); - } - - private PackerProjectContext projectContext() { - return new PackerProjectContext(projectReference.name(), projectReference.rootPath()); - } - - @Override - public void updateSearchQuery(String searchQuery) { - this.searchQuery = Objects.requireNonNullElse(searchQuery, ""); - requestNavigatorRedraw(); - } - - @Override - public void updateActiveFilters(EnumSet filters) { - activeFilters.clear(); - if (filters != null) { - activeFilters.addAll(filters); - } - requestNavigatorRedraw(); - } - - @Override - public void requestMutationPreview(AssetWorkspaceAction action) { - final AssetWorkspaceAssetSummary selectedAsset = state.selectedAsset().orElse(null); - if (selectedAsset == null) { - return; - } - if (action == AssetWorkspaceAction.RELOCATE) { - runRelocateFlow(selectedAsset); - return; - } - if (action == AssetWorkspaceAction.REGISTER) { - runRegisterFlow(selectedAsset); - return; - } - if (action == AssetWorkspaceAction.INCLUDE_IN_BUILD) { - runDirectMutationFlow(selectedAsset, action); - return; - } - if (action == AssetWorkspaceAction.EXCLUDE_FROM_BUILD || action == AssetWorkspaceAction.REMOVE) { - runModalMutationFlow(selectedAsset, action); - return; - } - try { - stagedMutationPreview = mutationService.preview(projectReference, selectedAsset, action, null); - appendLog("Preview ready for " + actionLabel(action) + "."); - requestDetailsRedraw(); - Platform.runLater(detailsControl::scrollToTop); - } catch (RuntimeException runtimeException) { - final String message = rootCauseMessage(runtimeException); - appendLog("Preview failed: " + message); - workspaceBus.publish(new StudioAssetsMutationFailedEvent(projectReference, action, message)); - stagedMutationPreview = null; - requestDetailsRedraw(); - } - } - - @Override - public void cancelStagedMutationPreview() { - stagedMutationPreview = null; - requestDetailsRedraw(); - } - - @Override - public void updatePreviewInput(Path input) { - selectedPreviewInput = input; - selectedPreviewZoom = 1; - requestDetailsRedraw(); - } - - @Override - public void updatePreviewZoom(int zoom) { - selectedPreviewZoom = zoom; - requestDetailsRedraw(); - } - - private void runRegisterFlow(AssetWorkspaceAssetSummary selectedAsset) { - runDirectMutationFlow(selectedAsset, AssetWorkspaceAction.REGISTER); - } - - private void runDirectMutationFlow(AssetWorkspaceAssetSummary selectedAsset, AssetWorkspaceAction action) { - try { - final AssetWorkspaceMutationPreview preview = - mutationService.preview(projectReference, selectedAsset, action, null); - if (!preview.canApply()) { - stagedMutationPreview = preview; - appendLog(actionLabel(action) + " blocked."); - requestDetailsRedraw(); - return; - } - mutationService.apply(projectReference, preview); - appendLog("Applied " + actionLabel(preview.action()) + "."); - stagedMutationPreview = null; - if (AssetWorkspaceMutationUpdatePlanner.forSuccessfulAction(preview.action()) == AssetWorkspaceMutationUpdateStrategy.LOCAL_PATCH) { - applyMutationSummaryPatch(preview); - } else { - requestStructuralSync("mutation applied: " + preview.action().name().toLowerCase(Locale.ROOT), null); - } - } catch (RuntimeException runtimeException) { - final String message = rootCauseMessage(runtimeException); - appendLog("Mutation failed: " + message); - workspaceBus.publish(new StudioAssetsMutationFailedEvent(projectReference, action, message)); - stagedMutationPreview = null; - requestDetailsRedraw(); - } - } - - private void runRelocateFlow(AssetWorkspaceAssetSummary selectedAsset) { - if (root.getScene() == null || root.getScene().getWindow() == null) { - return; - } - AssetRelocationWizard.showAndWait(root.getScene().getWindow(), projectReference, selectedAsset, mutationService) - .ifPresent(preview -> { - appendLog("Applied " + actionLabel(preview.action()) + "."); - stagedMutationPreview = null; - requestStructuralSync("mutation applied: " + preview.action().name().toLowerCase(Locale.ROOT), null); - }); - } - - private void runModalMutationFlow(AssetWorkspaceAssetSummary selectedAsset, AssetWorkspaceAction action) { - try { - final AssetWorkspaceMutationPreview preview = mutationService.preview(projectReference, selectedAsset, action, null); - if (!showMutationConfirmationModal(preview)) { - return; - } - mutationService.apply(projectReference, preview); - appendLog("Applied " + actionLabel(preview.action()) + "."); - stagedMutationPreview = null; - if (AssetWorkspaceMutationUpdatePlanner.forSuccessfulAction(preview.action()) == AssetWorkspaceMutationUpdateStrategy.LOCAL_PATCH) { - applyMutationSummaryPatch(preview); - } else { - requestStructuralSync("mutation applied: " + preview.action().name().toLowerCase(Locale.ROOT), null); - } - } catch (RuntimeException runtimeException) { - final String message = rootCauseMessage(runtimeException); - appendLog("Mutation failed: " + message); - workspaceBus.publish(new StudioAssetsMutationFailedEvent(projectReference, action, message)); - stagedMutationPreview = null; - requestDetailsRedraw(); - } - } - - private void applyMutationSummaryPatch(AssetWorkspaceMutationPreview preview) { - final AssetWorkspaceAssetSummary updatedSummary = switch (preview.action()) { - case INCLUDE_IN_BUILD -> withBuildParticipation(preview.asset(), AssetWorkspaceBuildParticipation.INCLUDED); - case EXCLUDE_FROM_BUILD -> withBuildParticipation(preview.asset(), AssetWorkspaceBuildParticipation.EXCLUDED); - default -> null; - }; - if (updatedSummary == null) { - return; - } - workspaceBus.publish(new StudioAssetsAssetSummaryPatchedEvent(projectReference, updatedSummary)); - } - - 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 preview) { - final VBox box = new VBox(6); - box.getChildren().add(createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_NAME), preview.asset().assetName())); - box.getChildren().add(createKeyValueRow( - Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), - projectRelativePath(preview.asset().assetRoot()))); - if (preview.targetAssetRoot() != null) { - box.getChildren().add(createKeyValueRow( - Container.i18n().text(I18n.ASSETS_LABEL_TARGET_LOCATION), - projectRelativePath(preview.targetAssetRoot()))); - } - return box; - } - - private Node createMutationChangesContent(List changes, String emptyText) { - if (changes.isEmpty()) { - return createSectionMessage(emptyText); - } - 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(List messages, String styleClass, String emptyText) { - if (messages.isEmpty()) { - return createSectionMessage(emptyText); - } - 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 boolean showMutationConfirmationModal(AssetWorkspaceMutationPreview preview) { - if (root.getScene() == null || root.getScene().getWindow() == null) { - return false; - } - - final Dialog dialog = new Dialog<>(); - dialog.initOwner(root.getScene().getWindow()); - dialog.setTitle(Container.i18n().text(I18n.ASSETS_MUTATION_CONFIRM_TITLE)); - dialog.setHeaderText(Container.i18n().format(I18n.ASSETS_MUTATION_CONFIRM_HEADER, actionLabel(preview.action()))); - dialog.getDialogPane().getButtonTypes().addAll(ButtonType.CANCEL, ButtonType.OK); - - final VBox content = new VBox(12); - content.setPadding(new Insets(8, 0, 0, 0)); - content.getChildren().add(createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_AFFECTED_ASSET), - createAffectedAssetContent(preview))); - - final AssetWorkspaceMutationImpactViewModel impacts = AssetWorkspaceMutationImpactViewModel.from(preview); - content.getChildren().add(createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_REGISTRY_IMPACT), - createMutationChangesContent(impacts.registryChanges(), Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_REGISTRY_IMPACT)))); - content.getChildren().add(createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_WORKSPACE_IMPACT), - createMutationChangesContent(impacts.workspaceChanges(), Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_WORKSPACE_IMPACT)))); - content.getChildren().add(createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_BLOCKERS), - createMutationMessages(preview.blockers(), "assets-mutation-message-blocker", Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_BLOCKERS)))); - content.getChildren().add(createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_WARNINGS), - createMutationMessages(preview.warnings(), "assets-mutation-message-warning", Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_WARNINGS)))); - content.getChildren().add(createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_SAFE_FIXES), - createMutationMessages(preview.safeFixes(), "assets-mutation-message-safe-fix", Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_SAFE_FIXES)))); - - final ScrollPane scrollPane = new ScrollPane(content); - scrollPane.setFitToWidth(true); - scrollPane.setPrefViewportWidth(640); - scrollPane.setPrefViewportHeight(520); - dialog.getDialogPane().setContent(scrollPane); - - final Node okButton = dialog.getDialogPane().lookupButton(ButtonType.OK); - okButton.setDisable(!preview.canApply()); - return dialog.showAndWait().filter(ButtonType.OK::equals).isPresent(); - } - - @Override - public void applyStagedMutation(AssetWorkspaceMutationPreview preview) { - if (!preview.canApply()) { - return; - } - try { - mutationService.apply(projectReference, preview); - appendLog("Applied " + actionLabel(preview.action()) + "."); - stagedMutationPreview = null; - if (AssetWorkspaceMutationUpdatePlanner.forSuccessfulAction(preview.action()) == AssetWorkspaceMutationUpdateStrategy.LOCAL_PATCH) { - applyMutationSummaryPatch(preview); - } else { - requestStructuralSync("mutation applied: " + preview.action().name().toLowerCase(Locale.ROOT), null); - } - } catch (RuntimeException runtimeException) { - final String message = rootCauseMessage(runtimeException); - appendLog("Mutation failed: " + message); - stagedMutationPreview = preview; - requestDetailsRedraw(); - } - } - - private void requestStructuralSync(String reason, AssetWorkspaceSelectionKey preferredSelectionKey) { - workspaceBus.publish(new StudioAssetsStructuralSyncRequestedEvent(projectReference, preferredSelectionKey, reason)); - } - - private Path firstPreviewInput(AssetWorkspaceAssetDetails details) { - return details.inputsByRole().values().stream() - .flatMap(List::stream) - .findFirst() - .orElse(null); - } - - static String readPreviewText(Path input) { - try { - final String text = Files.readString(input); - return text.length() > 4000 ? text.substring(0, 4000) + "\n\n…" : text; - } catch (IOException ioException) { - return Container.i18n().text(I18n.ASSETS_PREVIEW_TEXT_ERROR); - } - } - - private Node createSectionMessage(String text) { - final Label label = new Label(text); - label.setWrapText(true); - label.getStyleClass().add("assets-details-section-message"); - return label; - } - - private Node createKeyValueRow(String key, String value) { - final Label valueLabel = new Label(value); - valueLabel.getStyleClass().add("assets-details-value"); - valueLabel.setWrapText(true); - return createKeyValueRow(key, valueLabel); - } - - private Node createKeyValueRow(String key, Node valueNode) { - final HBox row = new HBox(12); - row.setAlignment(Pos.TOP_LEFT); - final Label keyLabel = new Label(key); - keyLabel.getStyleClass().add("assets-details-key"); - HBox.setHgrow(valueNode, Priority.ALWAYS); - row.getChildren().addAll(keyLabel, valueNode); - return row; - } - - private String yesNo(boolean value) { - return value ? Container.i18n().text(I18n.ASSETS_VALUE_YES) : Container.i18n().text(I18n.ASSETS_VALUE_NO); - } - - private Path projectRoot() { - return projectReference.rootPath().toAbsolutePath().normalize(); - } - - private String projectRelativePath(Path path) { - final Path normalizedPath = path.toAbsolutePath().normalize(); - try { - return projectRoot().relativize(normalizedPath).toString().replace('\\', '/'); - } catch (IllegalArgumentException ignored) { - return normalizedPath.toString().replace('\\', '/'); - } - } - - private void setInlineProgress(String message, double progress, boolean visible) { - inlineProgressLabel.setText(message); - inlineProgressBar.setVisible(visible); - inlineProgressBar.setManaged(visible); - inlineProgressBar.setProgress(progress); - } - - private void appendLog(String message) { - logsArea.appendText(message + System.lineSeparator()); - } - - private String rootCauseMessage(Throwable throwable) { - Throwable current = throwable; - while (current.getCause() != null) { - current = current.getCause(); - } - return current.getMessage() == null || current.getMessage().isBlank() - ? current.getClass().getSimpleName() - : current.getMessage(); + private SplitPane createAssetSplitPane() { + final var splitPane = new SplitPane(assetListControl, detailsControl); + splitPane.setDividerPositions(0.34); + splitPane.getStyleClass().add("assets-workspace-split"); + VBox.setVgrow(splitPane, Priority.ALWAYS); + return splitPane; } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAction.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAction.java deleted file mode 100644 index c0eeb6d0..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAction.java +++ /dev/null @@ -1,9 +0,0 @@ -package p.studio.workspaces.assets; - -public enum AssetWorkspaceAction { - REGISTER, - INCLUDE_IN_BUILD, - EXCLUDE_FROM_BUILD, - RELOCATE, - REMOVE -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSet.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSet.java deleted file mode 100644 index 42693dd6..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSet.java +++ /dev/null @@ -1,13 +0,0 @@ -package p.studio.workspaces.assets; - -import java.util.List; -import java.util.Objects; - -public record AssetWorkspaceActionSet( - List primaryActions, - List sensitiveActions) { - public AssetWorkspaceActionSet { - primaryActions = List.copyOf(Objects.requireNonNull(primaryActions, "primaryActions")); - sensitiveActions = List.copyOf(Objects.requireNonNull(sensitiveActions, "sensitiveActions")); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilder.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilder.java deleted file mode 100644 index 1576c858..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilder.java +++ /dev/null @@ -1,30 +0,0 @@ -package p.studio.workspaces.assets; - -import java.util.List; -import java.util.Objects; - -public final class AssetWorkspaceActionSetBuilder { - private AssetWorkspaceActionSetBuilder() { - } - - public static AssetWorkspaceActionSet forAsset(AssetWorkspaceAssetSummary summary) { - Objects.requireNonNull(summary, "summary"); - return switch (summary.state()) { - case REGISTERED -> summary.buildParticipation() == AssetWorkspaceBuildParticipation.INCLUDED - ? new AssetWorkspaceActionSet( - List.of(), - List.of( - AssetWorkspaceAction.EXCLUDE_FROM_BUILD, - AssetWorkspaceAction.RELOCATE, - AssetWorkspaceAction.REMOVE)) - : new AssetWorkspaceActionSet( - List.of(AssetWorkspaceAction.INCLUDE_IN_BUILD), - List.of( - AssetWorkspaceAction.RELOCATE, - AssetWorkspaceAction.REMOVE)); - case UNREGISTERED -> new AssetWorkspaceActionSet( - List.of(AssetWorkspaceAction.REGISTER), - List.of(AssetWorkspaceAction.RELOCATE)); - }; - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetDetails.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetDetails.java deleted file mode 100644 index b493258e..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetDetails.java +++ /dev/null @@ -1,22 +0,0 @@ -package p.studio.workspaces.assets; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -public record AssetWorkspaceAssetDetails( - AssetWorkspaceAssetSummary summary, - String outputFormat, - String outputCodec, - Map> inputsByRole, - List diagnostics) { - - public AssetWorkspaceAssetDetails { - Objects.requireNonNull(summary, "summary"); - outputFormat = Objects.requireNonNullElse(outputFormat, "unknown"); - outputCodec = Objects.requireNonNullElse(outputCodec, "unknown"); - inputsByRole = Map.copyOf(Objects.requireNonNull(inputsByRole, "inputsByRole")); - diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDetailsControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDetailsControl.java deleted file mode 100644 index 1ce2ced7..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDetailsControl.java +++ /dev/null @@ -1,125 +0,0 @@ -package p.studio.workspaces.assets; - -import javafx.scene.control.Label; -import javafx.scene.control.ScrollPane; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; -import p.studio.Container; -import p.studio.controls.lifecycle.StudioControlLifecycle; -import p.studio.controls.lifecycle.StudioControlLifecycleSupport; -import p.studio.events.StudioAssetsDetailsViewStateChangedEvent; -import p.studio.events.StudioWorkspaceEventBus; -import p.studio.projects.ProjectReference; -import p.studio.utilities.i18n.I18n; -import p.studio.workspaces.framework.StudioSubscriptionBag; - -import java.util.Objects; - -final class AssetWorkspaceDetailsControl extends VBox implements StudioControlLifecycle { - private final ProjectReference projectReference; - private final StudioWorkspaceEventBus workspaceBus; - private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag(); - private final Label workspaceSummaryLabel = new Label(); - private final VBox detailsContent = new VBox(12); - private final ScrollPane detailsScroll = new ScrollPane(); - private final AssetWorkspaceSummaryActionsControl summaryActionsControl; - private final AssetWorkspaceRuntimeContractControl runtimeContractControl; - private final AssetWorkspaceInputsPreviewControl inputsPreviewControl; - private final AssetWorkspaceDiagnosticsControl diagnosticsControl; - - private AssetWorkspaceDetailsViewState viewState; - private boolean readyMounted; - - AssetWorkspaceDetailsControl( - ProjectReference projectReference, - StudioWorkspaceEventBus workspaceBus, - AssetWorkspaceInteractionPort interactions) { - StudioControlLifecycleSupport.install(this, this); - this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); - this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); - this.summaryActionsControl = new AssetWorkspaceSummaryActionsControl(projectReference, workspaceBus, interactions); - this.runtimeContractControl = new AssetWorkspaceRuntimeContractControl(projectReference, workspaceBus, interactions); - this.inputsPreviewControl = new AssetWorkspaceInputsPreviewControl(projectReference, workspaceBus, interactions); - this.diagnosticsControl = new AssetWorkspaceDiagnosticsControl(projectReference, workspaceBus); - - getStyleClass().add("assets-workspace-pane"); - setSpacing(10); - - final Label detailsTitle = new Label(); - detailsTitle.textProperty().bind(Container.i18n().bind(I18n.ASSETS_DETAILS_TITLE)); - detailsTitle.getStyleClass().add("assets-workspace-pane-title"); - - workspaceSummaryLabel.getStyleClass().add("assets-workspace-summary"); - detailsContent.getStyleClass().add("assets-workspace-details-content"); - detailsScroll.setContent(detailsContent); - detailsScroll.setFitToWidth(true); - detailsScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); - detailsScroll.getStyleClass().add("assets-workspace-details-scroll"); - - getChildren().addAll(detailsTitle, workspaceSummaryLabel, detailsScroll); - VBox.setVgrow(detailsScroll, Priority.ALWAYS); - } - - @Override - public void subscribe() { - subscriptions.add(workspaceBus.subscribe(StudioAssetsDetailsViewStateChangedEvent.class, event -> { - if (projectReference.equals(event.project())) { - viewState = event.viewState(); - render(); - } - })); - } - - @Override - public void unsubscribe() { - subscriptions.clear(); - } - - void scrollToTop() { - detailsScroll.setVvalue(0.0d); - } - - private void render() { - if (viewState == null) { - readyMounted = false; - workspaceSummaryLabel.setText(Container.i18n().text(I18n.ASSETS_SUMMARY_LOADING)); - detailsContent.getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING))); - return; - } - - switch (viewState.workspaceState().status()) { - case LOADING -> { - readyMounted = false; - workspaceSummaryLabel.setText(Container.i18n().text(I18n.ASSETS_SUMMARY_LOADING)); - detailsContent.getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING))); - } - case EMPTY -> { - readyMounted = false; - workspaceSummaryLabel.setText(Container.i18n().text(I18n.ASSETS_SUMMARY_EMPTY)); - detailsContent.getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY))); - } - case ERROR -> { - readyMounted = false; - workspaceSummaryLabel.setText(Container.i18n().text(I18n.ASSETS_SUMMARY_ERROR)); - detailsContent.getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSectionMessage(viewState.workspaceState().errorMessage())); - } - case READY -> { - workspaceSummaryLabel.setText(Container.i18n().format(I18n.ASSETS_SUMMARY_READY, viewState.workspaceState().assets().size())); - if (viewState.workspaceState().selectedAsset().isEmpty()) { - readyMounted = false; - detailsContent.getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_NO_SELECTION))); - return; - } - - if (!readyMounted) { - readyMounted = true; - detailsContent.getChildren().setAll( - summaryActionsControl, - runtimeContractControl, - inputsPreviewControl, - diagnosticsControl); - } - } - } - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDetailsUiSupport.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDetailsUiSupport.java deleted file mode 100644 index c0fee7f6..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDetailsUiSupport.java +++ /dev/null @@ -1,125 +0,0 @@ -package p.studio.workspaces.assets; - -import javafx.geometry.Pos; -import javafx.scene.Node; -import javafx.scene.control.Label; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; -import p.studio.Container; -import p.studio.projects.ProjectReference; -import p.studio.utilities.i18n.I18n; - -import java.nio.file.Path; -import java.util.List; -import java.util.Locale; - -final class AssetWorkspaceDetailsUiSupport { - private AssetWorkspaceDetailsUiSupport() { - } - - static VBox createSection(String title, Node content) { - final VBox section = new VBox(10); - section.getStyleClass().add("assets-details-section"); - final Label titleLabel = new Label(title); - titleLabel.getStyleClass().add("assets-details-section-title"); - section.getChildren().addAll(titleLabel, content); - return section; - } - - static Node createSectionMessage(String text) { - final Label label = new Label(text); - label.setWrapText(true); - label.getStyleClass().add("assets-details-section-message"); - return label; - } - - static Node createKeyValueRow(String key, String value) { - final Label valueLabel = new Label(value); - valueLabel.getStyleClass().add("assets-details-value"); - valueLabel.setWrapText(true); - return createKeyValueRow(key, valueLabel); - } - - static Node createKeyValueRow(String key, Node valueNode) { - final HBox row = new HBox(12); - row.setAlignment(Pos.TOP_LEFT); - final Label keyLabel = new Label(key); - keyLabel.getStyleClass().add("assets-details-key"); - HBox.setHgrow(valueNode, Priority.ALWAYS); - row.getChildren().addAll(keyLabel, valueNode); - return row; - } - - static String registrationLabel(AssetWorkspaceAssetState state) { - return switch (state) { - case REGISTERED -> Container.i18n().text(I18n.ASSETS_VALUE_REGISTERED); - case UNREGISTERED -> Container.i18n().text(I18n.ASSETS_VALUE_UNREGISTERED); - }; - } - - static String buildParticipationLabel(AssetWorkspaceBuildParticipation buildParticipation) { - return switch (buildParticipation) { - case INCLUDED -> Container.i18n().text(I18n.ASSETS_VALUE_INCLUDED); - case EXCLUDED -> Container.i18n().text(I18n.ASSETS_VALUE_EXCLUDED); - }; - } - - static String yesNo(boolean value) { - return value ? Container.i18n().text(I18n.ASSETS_VALUE_YES) : Container.i18n().text(I18n.ASSETS_VALUE_NO); - } - - static String projectRelativePath(ProjectReference projectReference, Path path) { - try { - return projectReference.rootPath().relativize(path.toAbsolutePath().normalize()).toString(); - } catch (RuntimeException runtimeException) { - return path.toString(); - } - } - - static String actionLabel(AssetWorkspaceAction action) { - return switch (action) { - case REGISTER -> Container.i18n().text(I18n.ASSETS_ACTION_REGISTER); - case INCLUDE_IN_BUILD -> Container.i18n().text(I18n.ASSETS_ACTION_INCLUDE_IN_BUILD); - case EXCLUDE_FROM_BUILD -> Container.i18n().text(I18n.ASSETS_ACTION_EXCLUDE_FROM_BUILD); - case RELOCATE -> Container.i18n().text(I18n.ASSETS_ACTION_RELOCATE); - case REMOVE -> Container.i18n().text(I18n.ASSETS_ACTION_REMOVE); - }; - } - - static String actionButtonVariant(AssetWorkspaceAction action, boolean sensitive) { - if (!sensitive) { - return "studio-button-primary"; - } - return switch (action) { - case RELOCATE -> "studio-button-warning"; - case EXCLUDE_FROM_BUILD, REMOVE -> "studio-button-danger"; - default -> "studio-button-secondary"; - }; - } - - static boolean containsInput(AssetWorkspaceAssetDetails details, Path input) { - return details.inputsByRole().values().stream().flatMap(List::stream).anyMatch(input::equals); - } - - static String extensionOf(Path input) { - final String fileName = input.getFileName().toString(); - final int dot = fileName.lastIndexOf('.'); - if (dot < 0 || dot == fileName.length() - 1) { - return ""; - } - return fileName.substring(dot + 1).toLowerCase(Locale.ROOT); - } - - static boolean isImage(String extension) { - return extension.equals("png") || extension.equals("jpg") || extension.equals("jpeg") || extension.equals("gif"); - } - - static boolean isText(String extension) { - return extension.equals("txt") || extension.equals("json") || extension.equals("md") || extension.equals("xml") || extension.equals("csv"); - } - - static boolean isAudio(String extension) { - return extension.equals("wav") || extension.equals("mp3") || extension.equals("ogg"); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDetailsViewState.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDetailsViewState.java deleted file mode 100644 index 3fba520e..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDetailsViewState.java +++ /dev/null @@ -1,19 +0,0 @@ -package p.studio.workspaces.assets; - -import java.nio.file.Path; -import java.util.Objects; - -public record AssetWorkspaceDetailsViewState( - AssetWorkspaceState workspaceState, - AssetWorkspaceDetailsStatus detailsStatus, - AssetWorkspaceAssetDetails selectedAssetDetails, - String detailsErrorMessage, - AssetWorkspaceMutationPreview stagedMutationPreview, - Path selectedPreviewInput, - int selectedPreviewZoom) { - - public AssetWorkspaceDetailsViewState { - Objects.requireNonNull(workspaceState, "workspaceState"); - Objects.requireNonNull(detailsStatus, "detailsStatus"); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDiagnostic.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDiagnostic.java deleted file mode 100644 index 9dbf9156..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDiagnostic.java +++ /dev/null @@ -1,16 +0,0 @@ -package p.studio.workspaces.assets; - -import java.util.Objects; - -public record AssetWorkspaceDiagnostic( - AssetWorkspaceDiagnosticSeverity severity, - String message) { - - public AssetWorkspaceDiagnostic { - Objects.requireNonNull(severity, "severity"); - message = Objects.requireNonNull(message, "message").trim(); - if (message.isBlank()) { - throw new IllegalArgumentException("message must not be blank"); - } - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDiagnosticSeverity.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDiagnosticSeverity.java deleted file mode 100644 index 1c7c2c75..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDiagnosticSeverity.java +++ /dev/null @@ -1,7 +0,0 @@ -package p.studio.workspaces.assets; - -public enum AssetWorkspaceDiagnosticSeverity { - BLOCKER, - WARNING, - HINT -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDiagnosticsControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDiagnosticsControl.java deleted file mode 100644 index ee678a98..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDiagnosticsControl.java +++ /dev/null @@ -1,85 +0,0 @@ -package p.studio.workspaces.assets; - -import javafx.scene.control.Label; -import javafx.scene.layout.VBox; -import p.studio.Container; -import p.studio.controls.lifecycle.StudioControlLifecycle; -import p.studio.controls.lifecycle.StudioControlLifecycleSupport; -import p.studio.events.StudioAssetsDetailsViewStateChangedEvent; -import p.studio.events.StudioWorkspaceEventBus; -import p.studio.projects.ProjectReference; -import p.studio.utilities.i18n.I18n; -import p.studio.workspaces.framework.StudioSubscriptionBag; - -import java.util.Objects; - -final class AssetWorkspaceDiagnosticsControl extends VBox implements StudioControlLifecycle { - private final ProjectReference projectReference; - private final StudioWorkspaceEventBus workspaceBus; - private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag(); - private AssetWorkspaceDetailsViewState viewState; - - AssetWorkspaceDiagnosticsControl(ProjectReference projectReference, StudioWorkspaceEventBus workspaceBus) { - StudioControlLifecycleSupport.install(this, this); - this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); - this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); - } - - @Override - public void subscribe() { - subscriptions.add(workspaceBus.subscribe(StudioAssetsDetailsViewStateChangedEvent.class, event -> { - if (projectReference.equals(event.project())) { - viewState = event.viewState(); - render(); - } - })); - } - - @Override - public void unsubscribe() { - subscriptions.clear(); - } - - private void render() { - if (viewState == null || viewState.workspaceState().selectedAsset().isEmpty()) { - getChildren().clear(); - return; - } - - if (viewState.detailsStatus() == AssetWorkspaceDetailsStatus.LOADING) { - getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSection( - Container.i18n().text(I18n.ASSETS_SECTION_DIAGNOSTICS), - AssetWorkspaceDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING)))); - return; - } - - if (viewState.detailsStatus() == AssetWorkspaceDetailsStatus.ERROR || viewState.selectedAssetDetails() == null) { - getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSection( - Container.i18n().text(I18n.ASSETS_SECTION_DIAGNOSTICS), - AssetWorkspaceDetailsUiSupport.createSectionMessage(Objects.requireNonNullElse(viewState.detailsErrorMessage(), Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY))))); - return; - } - - if (viewState.selectedAssetDetails().diagnostics().isEmpty()) { - getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSection( - Container.i18n().text(I18n.ASSETS_SECTION_DIAGNOSTICS), - AssetWorkspaceDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DIAGNOSTICS_EMPTY)))); - return; - } - - final VBox diagnosticsBox = new VBox(8); - for (AssetWorkspaceDiagnostic diagnostic : viewState.selectedAssetDetails().diagnostics()) { - final VBox card = new VBox(4); - card.getStyleClass().add("assets-details-diagnostic-card"); - card.getStyleClass().add("assets-details-diagnostic-" + diagnostic.severity().name().toLowerCase()); - final Label severity = new Label(diagnostic.severity().name()); - severity.getStyleClass().add("assets-details-diagnostic-severity"); - final Label message = new Label(diagnostic.message()); - message.getStyleClass().add("assets-details-diagnostic-message"); - message.setWrapText(true); - card.getChildren().addAll(severity, message); - diagnosticsBox.getChildren().add(card); - } - getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSection(Container.i18n().text(I18n.ASSETS_SECTION_DIAGNOSTICS), diagnosticsBox)); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceInputsPreviewControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceInputsPreviewControl.java deleted file mode 100644 index 3f92653f..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceInputsPreviewControl.java +++ /dev/null @@ -1,202 +0,0 @@ -package p.studio.workspaces.assets; - -import javafx.geometry.Orientation; -import javafx.geometry.Pos; -import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.control.SplitPane; -import javafx.scene.control.TextArea; -import javafx.scene.control.ToggleButton; -import javafx.scene.control.ToggleGroup; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; -import javafx.scene.layout.HBox; -import javafx.scene.layout.VBox; -import p.studio.Container; -import p.studio.controls.lifecycle.StudioControlLifecycle; -import p.studio.controls.lifecycle.StudioControlLifecycleSupport; -import p.studio.events.StudioAssetsDetailsViewStateChangedEvent; -import p.studio.events.StudioWorkspaceEventBus; -import p.studio.projects.ProjectReference; -import p.studio.utilities.i18n.I18n; -import p.studio.workspaces.framework.StudioSubscriptionBag; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -final class AssetWorkspaceInputsPreviewControl extends VBox implements StudioControlLifecycle { - private final ProjectReference projectReference; - private final StudioWorkspaceEventBus workspaceBus; - private final AssetWorkspaceInteractionPort interactions; - private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag(); - private AssetWorkspaceDetailsViewState viewState; - - AssetWorkspaceInputsPreviewControl( - ProjectReference projectReference, - StudioWorkspaceEventBus workspaceBus, - AssetWorkspaceInteractionPort interactions) { - StudioControlLifecycleSupport.install(this, this); - this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); - this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); - this.interactions = Objects.requireNonNull(interactions, "interactions"); - } - - @Override - public void subscribe() { - subscriptions.add(workspaceBus.subscribe(StudioAssetsDetailsViewStateChangedEvent.class, event -> { - if (projectReference.equals(event.project())) { - viewState = event.viewState(); - render(); - } - })); - } - - @Override - public void unsubscribe() { - subscriptions.clear(); - } - - private void render() { - if (viewState == null || viewState.workspaceState().selectedAsset().isEmpty()) { - getChildren().clear(); - return; - } - - if (viewState.detailsStatus() == AssetWorkspaceDetailsStatus.LOADING) { - getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSection( - Container.i18n().text(I18n.ASSETS_SECTION_INPUTS_PREVIEW), - AssetWorkspaceDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING)))); - return; - } - - if (viewState.detailsStatus() == AssetWorkspaceDetailsStatus.ERROR || viewState.selectedAssetDetails() == null) { - getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSection( - Container.i18n().text(I18n.ASSETS_SECTION_INPUTS_PREVIEW), - AssetWorkspaceDetailsUiSupport.createSectionMessage(Objects.requireNonNullElse(viewState.detailsErrorMessage(), Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY))))); - return; - } - - final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails(); - if (details.inputsByRole().isEmpty()) { - getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSection( - Container.i18n().text(I18n.ASSETS_SECTION_INPUTS_PREVIEW), - AssetWorkspaceDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_INPUTS_EMPTY)))); - return; - } - - final Path selectedPreviewInput = resolveSelectedPreviewInput(details); - final VBox inputsList = new VBox(8); - for (Map.Entry> entry : details.inputsByRole().entrySet()) { - final VBox roleBox = new VBox(6); - final Label roleLabel = new Label(entry.getKey()); - roleLabel.getStyleClass().add("assets-details-role-label"); - roleBox.getChildren().add(roleLabel); - for (Path input : entry.getValue()) { - final Button inputButton = new Button(input.getFileName().toString()); - inputButton.getStyleClass().addAll("assets-details-input-button", "studio-button", "studio-button-secondary"); - if (input.equals(selectedPreviewInput)) { - inputButton.getStyleClass().add("studio-button-active"); - } - inputButton.setMaxWidth(Double.MAX_VALUE); - inputButton.setOnAction(event -> interactions.updatePreviewInput(input)); - roleBox.getChildren().add(inputButton); - } - inputsList.getChildren().add(roleBox); - } - - final SplitPane splitPane = new SplitPane(inputsList, createPreviewPane(selectedPreviewInput)); - splitPane.setOrientation(Orientation.HORIZONTAL); - splitPane.setDividerPositions(0.34); - splitPane.getStyleClass().add("assets-details-input-preview-split"); - getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSection(Container.i18n().text(I18n.ASSETS_SECTION_INPUTS_PREVIEW), splitPane)); - } - - private Path resolveSelectedPreviewInput(AssetWorkspaceAssetDetails details) { - final Path selectedPreviewInput = viewState.selectedPreviewInput(); - if (selectedPreviewInput == null || !AssetWorkspaceDetailsUiSupport.containsInput(details, selectedPreviewInput)) { - return details.inputsByRole().values().stream().flatMap(List::stream).findFirst().orElse(null); - } - return selectedPreviewInput; - } - - private Node createPreviewPane(Path input) { - final VBox previewBox = new VBox(10); - previewBox.getStyleClass().add("assets-details-preview-pane"); - if (input == null) { - previewBox.getChildren().add(AssetWorkspaceDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_PREVIEW_EMPTY))); - return previewBox; - } - - final Label title = new Label(input.getFileName().toString()); - title.getStyleClass().add("assets-details-preview-title"); - previewBox.getChildren().add(title); - - final String extension = AssetWorkspaceDetailsUiSupport.extensionOf(input); - if (AssetWorkspaceDetailsUiSupport.isImage(extension)) { - try { - final Image image = new Image(input.toUri().toString(), false); - final ImageView imageView = new ImageView(image); - imageView.setPreserveRatio(true); - imageView.setSmooth(false); - imageView.getStyleClass().add("assets-details-preview-image"); - previewBox.getChildren().add(createPreviewZoomBar(image)); - AssetWorkspace.applyPreviewScale(image, imageView, viewState.selectedPreviewZoom()); - previewBox.getChildren().add(imageView); - } catch (RuntimeException runtimeException) { - previewBox.getChildren().add(AssetWorkspaceDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_PREVIEW_IMAGE_ERROR))); - } - previewBox.getChildren().add(AssetWorkspaceDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), AssetWorkspaceDetailsUiSupport.projectRelativePath(projectReference, input))); - return previewBox; - } - - if (AssetWorkspaceDetailsUiSupport.isText(extension)) { - final TextArea textArea = new TextArea(AssetWorkspace.readPreviewText(input)); - textArea.setWrapText(true); - textArea.setEditable(false); - textArea.getStyleClass().add("assets-details-preview-text"); - previewBox.getChildren().add(textArea); - return previewBox; - } - - if (AssetWorkspaceDetailsUiSupport.isAudio(extension)) { - previewBox.getChildren().add(AssetWorkspaceDetailsUiSupport.createSectionMessage(Container.i18n().format(I18n.ASSETS_PREVIEW_AUDIO_PLACEHOLDER, input.getFileName().toString()))); - previewBox.getChildren().add(AssetWorkspaceDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), AssetWorkspaceDetailsUiSupport.projectRelativePath(projectReference, input))); - return previewBox; - } - - previewBox.getChildren().add(AssetWorkspaceDetailsUiSupport.createSectionMessage(Container.i18n().format(I18n.ASSETS_PREVIEW_GENERIC_PLACEHOLDER, input.getFileName().toString()))); - previewBox.getChildren().add(AssetWorkspaceDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), AssetWorkspaceDetailsUiSupport.projectRelativePath(projectReference, input))); - return previewBox; - } - - private Node createPreviewZoomBar(Image image) { - final HBox zoomBar = new HBox(8); - zoomBar.setAlignment(Pos.CENTER_LEFT); - zoomBar.getStyleClass().add("assets-details-preview-zoom-bar"); - - final Label zoomLabel = new Label(Container.i18n().text(I18n.ASSETS_PREVIEW_ZOOM)); - zoomLabel.getStyleClass().add("assets-details-preview-zoom-label"); - zoomBar.getChildren().add(zoomLabel); - - final ToggleGroup zoomGroup = new ToggleGroup(); - final int maxZoom = AssetWorkspace.maxPreviewZoom(image); - for (int zoom : List.of(1, 2, 4, 8)) { - final ToggleButton button = new ToggleButton("x" + zoom); - button.getStyleClass().addAll( - "assets-details-preview-zoom-button", - "studio-button", - "studio-button-secondary", - "studio-button-pill", - "studio-button-toggle"); - button.setToggleGroup(zoomGroup); - button.setSelected(viewState.selectedPreviewZoom() == zoom); - button.setDisable(zoom > maxZoom); - button.setOnAction(event -> interactions.updatePreviewZoom(zoom)); - zoomBar.getChildren().add(button); - } - return zoomBar; - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceInteractionPort.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceInteractionPort.java deleted file mode 100644 index 0e8bf3c5..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceInteractionPort.java +++ /dev/null @@ -1,18 +0,0 @@ -package p.studio.workspaces.assets; - -import javafx.scene.control.CheckBox; - -import java.nio.file.Path; -import java.util.EnumSet; - -interface AssetWorkspaceInteractionPort { - void updateSearchQuery(String searchQuery); - void updateActiveFilters(EnumSet filters); - void selectAsset(AssetWorkspaceSelectionKey selectionKey); - void requestMutationPreview(AssetWorkspaceAction action); - void cancelStagedMutationPreview(); - void applyStagedMutation(AssetWorkspaceMutationPreview preview); - void updatePreviewInput(Path input); - void updatePreviewZoom(int zoom); - void updatePreload(AssetWorkspaceAssetDetails details, boolean preloadEnabled, CheckBox checkBox); -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationChange.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationChange.java deleted file mode 100644 index b40e4b62..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationChange.java +++ /dev/null @@ -1,18 +0,0 @@ -package p.studio.workspaces.assets; - -import java.util.Objects; - -public record AssetWorkspaceMutationChange( - AssetWorkspaceMutationChangeScope scope, - String verb, - String target) { - - public AssetWorkspaceMutationChange { - Objects.requireNonNull(scope, "scope"); - verb = Objects.requireNonNull(verb, "verb").trim(); - target = Objects.requireNonNull(target, "target").trim(); - if (verb.isBlank() || target.isBlank()) { - throw new IllegalArgumentException("verb and target must not be blank"); - } - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationChangeScope.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationChangeScope.java deleted file mode 100644 index f388bcb9..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationChangeScope.java +++ /dev/null @@ -1,6 +0,0 @@ -package p.studio.workspaces.assets; - -public enum AssetWorkspaceMutationChangeScope { - REGISTRY, - WORKSPACE -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModel.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModel.java deleted file mode 100644 index 015310bb..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModel.java +++ /dev/null @@ -1,25 +0,0 @@ -package p.studio.workspaces.assets; - -import java.util.List; -import java.util.Objects; - -public record AssetWorkspaceMutationImpactViewModel( - List registryChanges, - List workspaceChanges) { - - public AssetWorkspaceMutationImpactViewModel { - registryChanges = List.copyOf(Objects.requireNonNull(registryChanges, "registryChanges")); - workspaceChanges = List.copyOf(Objects.requireNonNull(workspaceChanges, "workspaceChanges")); - } - - public static AssetWorkspaceMutationImpactViewModel from(AssetWorkspaceMutationPreview preview) { - Objects.requireNonNull(preview, "preview"); - return new AssetWorkspaceMutationImpactViewModel( - preview.changes().stream() - .filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.REGISTRY) - .toList(), - preview.changes().stream() - .filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.WORKSPACE) - .toList()); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationPreview.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationPreview.java deleted file mode 100644 index 3c850fcb..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationPreview.java +++ /dev/null @@ -1,30 +0,0 @@ -package p.studio.workspaces.assets; - -import java.nio.file.Path; -import java.util.List; -import java.util.Objects; - -public record AssetWorkspaceMutationPreview( - AssetWorkspaceAction action, - AssetWorkspaceAssetSummary asset, - List blockers, - List warnings, - List safeFixes, - List changes, - boolean highRisk, - Path targetAssetRoot) { - - public AssetWorkspaceMutationPreview { - Objects.requireNonNull(action, "action"); - Objects.requireNonNull(asset, "asset"); - blockers = List.copyOf(Objects.requireNonNull(blockers, "blockers")); - warnings = List.copyOf(Objects.requireNonNull(warnings, "warnings")); - safeFixes = List.copyOf(Objects.requireNonNull(safeFixes, "safeFixes")); - changes = List.copyOf(Objects.requireNonNull(changes, "changes")); - targetAssetRoot = targetAssetRoot == null ? null : targetAssetRoot.toAbsolutePath().normalize(); - } - - public boolean canApply() { - return blockers.isEmpty(); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationService.java deleted file mode 100644 index 7bf01994..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationService.java +++ /dev/null @@ -1,22 +0,0 @@ -package p.studio.workspaces.assets; - -import p.studio.projects.ProjectReference; - -import java.nio.file.Path; - -public interface AssetWorkspaceMutationService { - default AssetWorkspaceMutationPreview preview( - ProjectReference projectReference, - AssetWorkspaceAssetSummary asset, - AssetWorkspaceAction action) { - return preview(projectReference, asset, action, null); - } - - AssetWorkspaceMutationPreview preview( - ProjectReference projectReference, - AssetWorkspaceAssetSummary asset, - AssetWorkspaceAction action, - Path targetRoot); - - void apply(ProjectReference projectReference, AssetWorkspaceMutationPreview preview); -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationUpdatePlanner.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationUpdatePlanner.java deleted file mode 100644 index 877cf70c..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationUpdatePlanner.java +++ /dev/null @@ -1,15 +0,0 @@ -package p.studio.workspaces.assets; - -import java.util.Objects; - -public final class AssetWorkspaceMutationUpdatePlanner { - private AssetWorkspaceMutationUpdatePlanner() { - } - - public static AssetWorkspaceMutationUpdateStrategy forSuccessfulAction(AssetWorkspaceAction action) { - return switch (Objects.requireNonNull(action, "action")) { - case INCLUDE_IN_BUILD, EXCLUDE_FROM_BUILD -> AssetWorkspaceMutationUpdateStrategy.LOCAL_PATCH; - case REGISTER, RELOCATE, REMOVE -> AssetWorkspaceMutationUpdateStrategy.STRUCTURAL_SYNC; - }; - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationUpdateStrategy.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationUpdateStrategy.java deleted file mode 100644 index bacbffba..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationUpdateStrategy.java +++ /dev/null @@ -1,6 +0,0 @@ -package p.studio.workspaces.assets; - -public enum AssetWorkspaceMutationUpdateStrategy { - LOCAL_PATCH, - STRUCTURAL_SYNC -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceNavigatorControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceNavigatorControl.java deleted file mode 100644 index 9b731068..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceNavigatorControl.java +++ /dev/null @@ -1,187 +0,0 @@ -package p.studio.workspaces.assets; - -import javafx.geometry.Insets; -import javafx.scene.Node; -import javafx.scene.control.Label; -import javafx.scene.control.ScrollPane; -import javafx.scene.control.TextField; -import javafx.scene.control.ToggleButton; -import javafx.scene.layout.FlowPane; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; -import p.studio.Container; -import p.studio.controls.lifecycle.StudioControlLifecycle; -import p.studio.controls.lifecycle.StudioControlLifecycleSupport; -import p.studio.events.StudioAssetsNavigatorViewStateChangedEvent; -import p.studio.events.StudioWorkspaceEventBus; -import p.studio.projects.ProjectReference; -import p.studio.utilities.i18n.I18n; -import p.studio.workspaces.framework.StudioSubscriptionBag; - -import java.nio.file.Path; -import java.util.EnumMap; -import java.util.EnumSet; -import java.util.Map; -import java.util.Objects; - -final class AssetWorkspaceNavigatorControl extends VBox implements StudioControlLifecycle { - private final ProjectReference projectReference; - private final Path projectRoot; - private final StudioWorkspaceEventBus workspaceBus; - private final AssetWorkspaceInteractionPort interactions; - private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag(); - private final TextField searchField = new TextField(); - private final FlowPane filterBar = new FlowPane(); - private final Label navigatorStateLabel = new Label(); - private final VBox navigatorContent = new VBox(8); - private final Map filterButtons = new EnumMap<>(AssetNavigatorFilter.class); - - private AssetWorkspaceNavigatorViewState viewState; - private EnumSet activeFilters = EnumSet.noneOf(AssetNavigatorFilter.class); - private String projectionSignature = ""; - - AssetWorkspaceNavigatorControl( - ProjectReference projectReference, - StudioWorkspaceEventBus workspaceBus, - AssetWorkspaceInteractionPort interactions) { - StudioControlLifecycleSupport.install(this, this); - this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); - this.projectRoot = this.projectReference.rootPath(); - this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); - this.interactions = Objects.requireNonNull(interactions, "interactions"); - - getStyleClass().add("assets-workspace-pane"); - setSpacing(8); - - final Label navigatorTitle = new Label(); - navigatorTitle.textProperty().bind(Container.i18n().bind(I18n.ASSETS_NAVIGATOR_TITLE)); - navigatorTitle.getStyleClass().add("assets-workspace-pane-title"); - - searchField.setPromptText(Container.i18n().text(I18n.ASSETS_SEARCH_PROMPT)); - searchField.getStyleClass().add("assets-workspace-search"); - searchField.textProperty().addListener((ignored, oldValue, newValue) -> { - final String current = newValue == null ? "" : newValue; - if (!Objects.equals(oldValue, current)) { - interactions.updateSearchQuery(current); - } - }); - - configureFilterBar(); - - navigatorStateLabel.getStyleClass().add("assets-workspace-pane-body"); - navigatorContent.getStyleClass().add("assets-workspace-navigator-content"); - final ScrollPane navigatorScroll = new ScrollPane(navigatorContent); - navigatorScroll.setFitToWidth(true); - navigatorScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); - navigatorScroll.getStyleClass().add("assets-workspace-navigator-scroll"); - - getChildren().addAll(navigatorTitle, searchField, filterBar, navigatorStateLabel, navigatorScroll); - VBox.setVgrow(navigatorScroll, Priority.ALWAYS); - } - - @Override - public void subscribe() { - subscriptions.add(workspaceBus.subscribe(StudioAssetsNavigatorViewStateChangedEvent.class, event -> { - if (projectReference.equals(event.project())) { - applyViewState(event.viewState()); - } - })); - } - - @Override - public void unsubscribe() { - subscriptions.clear(); - } - - private void applyViewState(AssetWorkspaceNavigatorViewState viewState) { - this.viewState = viewState; - render(); - } - - private void configureFilterBar() { - filterBar.setHgap(6); - filterBar.setVgap(6); - filterBar.setPadding(new Insets(4, 0, 4, 0)); - filterBar.getStyleClass().add("assets-workspace-filter-bar"); - addFilterButton(AssetNavigatorFilter.REGISTERED, I18n.ASSETS_FILTER_REGISTERED); - addFilterButton(AssetNavigatorFilter.UNREGISTERED, I18n.ASSETS_FILTER_UNREGISTERED); - addFilterButton(AssetNavigatorFilter.DIAGNOSTICS, I18n.ASSETS_FILTER_DIAGNOSTICS); - addFilterButton(AssetNavigatorFilter.PRELOAD, I18n.ASSETS_FILTER_PRELOAD); - } - - private void addFilterButton(AssetNavigatorFilter filter, I18n i18n) { - final ToggleButton button = new ToggleButton(); - button.textProperty().bind(Container.i18n().bind(i18n)); - button.getStyleClass().addAll("studio-button", "studio-button-secondary", "studio-button-pill", "studio-button-toggle"); - button.selectedProperty().addListener((ignored, oldValue, selected) -> { - if (selected) { - activeFilters.add(filter); - } else { - activeFilters.remove(filter); - } - interactions.updateActiveFilters(activeFilters.isEmpty() ? EnumSet.noneOf(AssetNavigatorFilter.class) : EnumSet.copyOf(activeFilters)); - }); - filterButtons.put(filter, button); - filterBar.getChildren().add(button); - } - - private void render() { - if (viewState == null) { - navigatorStateLabel.setText(Container.i18n().text(I18n.ASSETS_STATE_LOADING)); - navigatorContent.getChildren().setAll(createMessage(Container.i18n().text(I18n.ASSETS_STATE_LOADING))); - return; - } - - navigatorStateLabel.setText(viewState.message()); - if (!viewState.hasProjection()) { - projectionSignature = ""; - navigatorContent.getChildren().setAll(createMessage(viewState.message())); - return; - } - - final String nextSignature = projectionSignature(viewState.projection()); - if (Objects.equals(projectionSignature, nextSignature)) { - return; - } - projectionSignature = nextSignature; - navigatorContent.getChildren().clear(); - for (AssetNavigatorGroup group : viewState.projection().groups()) { - final VBox groupBox = new VBox(6); - groupBox.getStyleClass().add("assets-workspace-group"); - - final Label groupLabel = new Label(group.label()); - groupLabel.getStyleClass().add("assets-workspace-group-label"); - groupBox.getChildren().add(groupLabel); - - for (AssetWorkspaceAssetSummary asset : group.assets()) { - groupBox.getChildren().add(new AssetWorkspaceNavigatorRowControl( - projectReference, - workspaceBus, - interactions, - asset, - asset.selectionKey().equals(viewState.workspaceState().selectedKey()))); - } - - navigatorContent.getChildren().add(groupBox); - } - } - - private Node createMessage(String text) { - final Label label = new Label(text); - label.getStyleClass().add("assets-workspace-empty-state"); - label.setWrapText(true); - return label; - } - - private String projectionSignature(AssetNavigatorProjection projection) { - final StringBuilder signature = new StringBuilder(); - for (AssetNavigatorGroup group : projection.groups()) { - signature.append(group.label()).append('|'); - for (AssetWorkspaceAssetSummary asset : group.assets()) { - signature.append(asset.selectionKey().stableKey()).append(','); - } - signature.append(';'); - } - return signature.toString(); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceNavigatorRowControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceNavigatorRowControl.java deleted file mode 100644 index b77e90e2..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceNavigatorRowControl.java +++ /dev/null @@ -1,153 +0,0 @@ -package p.studio.workspaces.assets; - -import javafx.geometry.Pos; -import javafx.scene.control.Label; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.Region; -import javafx.scene.layout.VBox; -import p.studio.Container; -import p.studio.controls.lifecycle.StudioControlLifecycle; -import p.studio.controls.lifecycle.StudioControlLifecycleSupport; -import p.studio.events.StudioAssetsAssetSummaryPatchedEvent; -import p.studio.events.StudioAssetsWorkspaceSelectionChangedEvent; -import p.studio.events.StudioWorkspaceEventBus; -import p.studio.projects.ProjectReference; -import p.studio.utilities.i18n.I18n; -import p.studio.workspaces.framework.StudioSubscriptionBag; - -import java.nio.file.Path; -import java.util.Objects; - -final class AssetWorkspaceNavigatorRowControl extends VBox implements StudioControlLifecycle { - private final ProjectReference projectReference; - private final Path projectRoot; - private final StudioWorkspaceEventBus workspaceBus; - private final AssetWorkspaceInteractionPort interactions; - private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag(); - - private AssetWorkspaceAssetSummary summary; - private boolean selected; - - AssetWorkspaceNavigatorRowControl( - ProjectReference projectReference, - StudioWorkspaceEventBus workspaceBus, - AssetWorkspaceInteractionPort interactions, - AssetWorkspaceAssetSummary summary, - boolean selected) { - StudioControlLifecycleSupport.install(this, this); - this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); - this.projectRoot = this.projectReference.rootPath(); - this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); - this.interactions = Objects.requireNonNull(interactions, "interactions"); - this.summary = Objects.requireNonNull(summary, "summary"); - this.selected = selected; - render(); - } - - @Override - public void subscribe() { - subscriptions.add(workspaceBus.subscribe(StudioAssetsWorkspaceSelectionChangedEvent.class, event -> { - if (projectReference.equals(event.project())) { - updateSelection(event.selectionKey().equals(summary.selectionKey())); - } - })); - subscriptions.add(workspaceBus.subscribe(StudioAssetsAssetSummaryPatchedEvent.class, event -> { - if (projectReference.equals(event.project()) - && summary.selectionKey().equals(event.summary().selectionKey())) { - summary = event.summary(); - render(); - } - })); - } - - @Override - public void unsubscribe() { - subscriptions.clear(); - } - - private void updateSelection(boolean selected) { - if (this.selected == selected) { - return; - } - this.selected = selected; - renderSelection(); - } - - private void render() { - getChildren().clear(); - getStyleClass().setAll("assets-workspace-asset-row", assetRowToneClass(summary.assetFamily())); - renderSelection(); - - final HBox topLine = new HBox(8); - topLine.setAlignment(Pos.CENTER_LEFT); - final Label name = new Label(summary.assetName()); - name.getStyleClass().addAll("assets-workspace-asset-name", assetNameToneClass(summary.assetFamily())); - final Region spacer = new Region(); - HBox.setHgrow(spacer, Priority.ALWAYS); - final HBox badges = new HBox(6); - badges.setAlignment(Pos.CENTER_RIGHT); - badges.getStyleClass().add("assets-workspace-asset-badges"); - populateBadges(badges); - topLine.getChildren().addAll(name, spacer, badges); - - final Label path = new Label(AssetNavigatorProjectionBuilder.relativeRoot(summary, projectRoot)); - path.getStyleClass().add("assets-workspace-asset-path"); - getChildren().setAll(topLine, path); - setOnMouseClicked(event -> interactions.selectAsset(summary.selectionKey())); - } - - private void renderSelection() { - getStyleClass().remove("assets-workspace-asset-row-selected"); - if (selected) { - getStyleClass().add("assets-workspace-asset-row-selected"); - } - } - - private void populateBadges(HBox badges) { - if (summary.state() == AssetWorkspaceAssetState.UNREGISTERED) { - badges.getChildren().add(createBadge(Container.i18n().text(I18n.ASSETS_BADGE_UNREGISTERED), "assets-workspace-badge-orphan")); - } else if (summary.buildParticipation() == AssetWorkspaceBuildParticipation.INCLUDED) { - badges.getChildren().add(createBadge(buildParticipationLabel(summary.buildParticipation()), "assets-workspace-badge-preload")); - if (summary.preload()) { - badges.getChildren().add(createBadge(Container.i18n().text(I18n.ASSETS_BADGE_PRELOAD), "assets-workspace-badge-preload")); - } - } else { - badges.getChildren().add(createBadge(buildParticipationLabel(summary.buildParticipation()), "assets-workspace-badge-diagnostics")); - } - if (summary.hasDiagnostics()) { - badges.getChildren().add(createBadge(Container.i18n().text(I18n.ASSETS_BADGE_DIAGNOSTICS), "assets-workspace-badge-diagnostics")); - } - } - - private Label createBadge(String text, String styleClass) { - final Label badge = new Label(text); - badge.getStyleClass().addAll("assets-workspace-badge", styleClass); - return badge; - } - - private String buildParticipationLabel(AssetWorkspaceBuildParticipation buildParticipation) { - return switch (buildParticipation) { - case INCLUDED -> Container.i18n().text(I18n.ASSETS_VALUE_INCLUDED); - case EXCLUDED -> Container.i18n().text(I18n.ASSETS_VALUE_EXCLUDED); - }; - } - - private String assetRowToneClass(String assetFamily) { - return switch (assetFamily == null ? "" : assetFamily.toLowerCase()) { - case "image_bank" -> "assets-workspace-asset-row-image"; - case "palette_bank" -> "assets-workspace-asset-row-palette"; - case "sound_bank" -> "assets-workspace-asset-row-sound"; - default -> "assets-workspace-asset-row-generic"; - }; - } - - private String assetNameToneClass(String assetFamily) { - return switch (assetFamily == null ? "" : assetFamily.toLowerCase()) { - case "image_bank" -> "assets-workspace-asset-name-image"; - case "palette_bank" -> "assets-workspace-asset-name-palette"; - case "sound_bank" -> "assets-workspace-asset-name-sound"; - default -> "assets-workspace-asset-name-generic"; - }; - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceRuntimeContractControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceRuntimeContractControl.java deleted file mode 100644 index 29571aad..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceRuntimeContractControl.java +++ /dev/null @@ -1,87 +0,0 @@ -package p.studio.workspaces.assets; - -import javafx.scene.control.CheckBox; -import javafx.scene.layout.VBox; -import p.studio.Container; -import p.studio.controls.lifecycle.StudioControlLifecycle; -import p.studio.controls.lifecycle.StudioControlLifecycleSupport; -import p.studio.events.StudioAssetsDetailsViewStateChangedEvent; -import p.studio.events.StudioWorkspaceEventBus; -import p.studio.projects.ProjectReference; -import p.studio.utilities.i18n.I18n; -import p.studio.workspaces.framework.StudioSubscriptionBag; - -import java.util.Objects; - -final class AssetWorkspaceRuntimeContractControl extends VBox implements StudioControlLifecycle { - private final ProjectReference projectReference; - private final StudioWorkspaceEventBus workspaceBus; - private final AssetWorkspaceInteractionPort interactions; - private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag(); - private AssetWorkspaceDetailsViewState viewState; - - AssetWorkspaceRuntimeContractControl( - ProjectReference projectReference, - StudioWorkspaceEventBus workspaceBus, - AssetWorkspaceInteractionPort interactions) { - StudioControlLifecycleSupport.install(this, this); - this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); - this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); - this.interactions = Objects.requireNonNull(interactions, "interactions"); - } - - @Override - public void subscribe() { - subscriptions.add(workspaceBus.subscribe(StudioAssetsDetailsViewStateChangedEvent.class, event -> { - if (projectReference.equals(event.project())) { - viewState = event.viewState(); - render(); - } - })); - } - - @Override - public void unsubscribe() { - subscriptions.clear(); - } - - private void render() { - if (viewState == null || viewState.workspaceState().selectedAsset().isEmpty()) { - getChildren().clear(); - return; - } - - if (viewState.detailsStatus() == AssetWorkspaceDetailsStatus.LOADING) { - getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSection( - Container.i18n().text(I18n.ASSETS_SECTION_RUNTIME_CONTRACT), - AssetWorkspaceDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING)))); - return; - } - - if (viewState.detailsStatus() == AssetWorkspaceDetailsStatus.ERROR || viewState.selectedAssetDetails() == null) { - getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSection( - Container.i18n().text(I18n.ASSETS_SECTION_RUNTIME_CONTRACT), - AssetWorkspaceDetailsUiSupport.createSectionMessage(Objects.requireNonNullElse(viewState.detailsErrorMessage(), Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY))))); - return; - } - - final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails(); - final VBox content = new VBox(8); - content.getChildren().addAll( - AssetWorkspaceDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_PRELOAD), createPreloadToggle(details)), - AssetWorkspaceDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_FORMAT), details.outputFormat()), - AssetWorkspaceDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_CODEC), details.outputCodec())); - getChildren().setAll(AssetWorkspaceDetailsUiSupport.createSection(Container.i18n().text(I18n.ASSETS_SECTION_RUNTIME_CONTRACT), content)); - } - - private CheckBox createPreloadToggle(AssetWorkspaceAssetDetails details) { - final boolean currentValue = details.summary().preload(); - final CheckBox checkBox = new CheckBox(AssetWorkspaceDetailsUiSupport.yesNo(currentValue)); - checkBox.setSelected(currentValue); - checkBox.setFocusTraversable(false); - checkBox.getStyleClass().add("assets-details-readonly-check"); - checkBox.selectedProperty().addListener((ignored, previous, selected) -> checkBox.setText(AssetWorkspaceDetailsUiSupport.yesNo(selected))); - checkBox.setOnAction(event -> interactions.updatePreload(details, checkBox.isSelected(), checkBox)); - return checkBox; - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceSelectionKey.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceSelectionKey.java deleted file mode 100644 index f6817bdf..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceSelectionKey.java +++ /dev/null @@ -1,32 +0,0 @@ -package p.studio.workspaces.assets; - -import java.nio.file.Path; -import java.util.Objects; - -public sealed interface AssetWorkspaceSelectionKey permits AssetWorkspaceSelectionKey.ManagedAsset, AssetWorkspaceSelectionKey.OrphanAsset { - String stableKey(); - - record ManagedAsset(int assetId) implements AssetWorkspaceSelectionKey { - public ManagedAsset { - if (assetId <= 0) { - throw new IllegalArgumentException("assetId must be positive"); - } - } - - @Override - public String stableKey() { - return "managed:" + assetId; - } - } - - record OrphanAsset(Path assetRoot) implements AssetWorkspaceSelectionKey { - public OrphanAsset { - assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); - } - - @Override - public String stableKey() { - return "orphan:" + assetRoot; - } - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceService.java deleted file mode 100644 index 4bacdd1d..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceService.java +++ /dev/null @@ -1,9 +0,0 @@ -package p.studio.workspaces.assets; - -import p.studio.projects.ProjectReference; - -public interface AssetWorkspaceService { - AssetWorkspaceSnapshot loadWorkspace(ProjectReference projectReference); - - AssetWorkspaceAssetDetails loadAssetDetails(ProjectReference projectReference, AssetWorkspaceSelectionKey selectionKey); -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceSnapshot.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceSnapshot.java deleted file mode 100644 index 65a39e72..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceSnapshot.java +++ /dev/null @@ -1,10 +0,0 @@ -package p.studio.workspaces.assets; - -import java.util.List; -import java.util.Objects; - -public record AssetWorkspaceSnapshot(List assets) { - public AssetWorkspaceSnapshot { - assets = List.copyOf(Objects.requireNonNull(assets, "assets")); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceState.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceState.java index c5dfd06a..46feffad 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceState.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceState.java @@ -1,5 +1,9 @@ package p.studio.workspaces.assets; +import p.packer.assets.AssetReference; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary; +import p.studio.workspaces.assets.messages.AssetWorkspaceStatus; + import java.util.Comparator; import java.util.List; import java.util.Objects; @@ -8,7 +12,7 @@ import java.util.Optional; public record AssetWorkspaceState( AssetWorkspaceStatus status, List assets, - AssetWorkspaceSelectionKey selectedKey, + AssetReference selectedAssetReference, String errorMessage) { public AssetWorkspaceState { @@ -20,12 +24,12 @@ public record AssetWorkspaceState( if (previous == null) { return new AssetWorkspaceState(AssetWorkspaceStatus.LOADING, List.of(), null, null); } - return new AssetWorkspaceState(AssetWorkspaceStatus.LOADING, previous.assets(), previous.selectedKey(), null); + return new AssetWorkspaceState(AssetWorkspaceStatus.LOADING, previous.assets(), previous.selectedAssetReference(), null); } - public static AssetWorkspaceState ready(List assets, AssetWorkspaceSelectionKey preferredSelectionKey) { + public static AssetWorkspaceState ready(List assets, AssetReference preferredAssetReference) { final List normalizedAssets = normalizeAssets(assets); - final AssetWorkspaceSelectionKey resolvedSelection = reconcileSelection(normalizedAssets, preferredSelectionKey); + final AssetReference resolvedSelection = reconcileSelection(normalizedAssets, preferredAssetReference); final AssetWorkspaceStatus status = normalizedAssets.isEmpty() ? AssetWorkspaceStatus.EMPTY : AssetWorkspaceStatus.READY; return new AssetWorkspaceState(status, normalizedAssets, resolvedSelection, null); } @@ -34,38 +38,38 @@ public record AssetWorkspaceState( return new AssetWorkspaceState( AssetWorkspaceStatus.ERROR, previous == null ? List.of() : previous.assets(), - previous == null ? null : previous.selectedKey(), + previous == null ? null : previous.selectedAssetReference(), Objects.requireNonNullElse(errorMessage, "Unknown asset workspace error")); } public Optional selectedAsset() { - if (selectedKey == null) { + if (selectedAssetReference == null) { return Optional.empty(); } return assets.stream() - .filter(asset -> asset.selectionKey().equals(selectedKey)) + .filter(asset -> asset.assetReference().equals(selectedAssetReference)) .findFirst(); } - public AssetWorkspaceState withSelection(AssetWorkspaceSelectionKey nextSelectionKey) { - final AssetWorkspaceSelectionKey resolvedSelection = reconcileSelection(assets, nextSelectionKey); + public AssetWorkspaceState withSelection(AssetReference nextAssetReference) { + final AssetReference resolvedSelection = reconcileSelection(assets, nextAssetReference); return new AssetWorkspaceState(status, assets, resolvedSelection, errorMessage); } - static AssetWorkspaceSelectionKey reconcileSelection( + static AssetReference reconcileSelection( List assets, - AssetWorkspaceSelectionKey preferredSelectionKey) { + AssetReference preferredAssetReference) { if (assets.isEmpty()) { return null; } - if (preferredSelectionKey != null) { + if (preferredAssetReference != null) { for (AssetWorkspaceAssetSummary asset : assets) { - if (asset.selectionKey().equals(preferredSelectionKey)) { - return preferredSelectionKey; + if (asset.assetReference().equals(preferredAssetReference)) { + return preferredAssetReference; } } } - return assets.getFirst().selectionKey(); + return assets.getFirst().assetReference(); } private static List normalizeAssets(List assets) { diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceSummaryActionsControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceSummaryActionsControl.java deleted file mode 100644 index fb13449b..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceSummaryActionsControl.java +++ /dev/null @@ -1,203 +0,0 @@ -package p.studio.workspaces.assets; - -import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; -import p.studio.Container; -import p.studio.controls.lifecycle.StudioControlLifecycle; -import p.studio.controls.lifecycle.StudioControlLifecycleSupport; -import p.studio.events.StudioAssetsDetailsViewStateChangedEvent; -import p.studio.events.StudioWorkspaceEventBus; -import p.studio.projects.ProjectReference; -import p.studio.utilities.i18n.I18n; -import p.studio.workspaces.framework.StudioSubscriptionBag; - -import java.util.List; -import java.util.Objects; - -final class AssetWorkspaceSummaryActionsControl extends HBox implements StudioControlLifecycle { - private final ProjectReference projectReference; - private final StudioWorkspaceEventBus workspaceBus; - private final AssetWorkspaceInteractionPort interactions; - private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag(); - private AssetWorkspaceDetailsViewState viewState; - - AssetWorkspaceSummaryActionsControl( - ProjectReference projectReference, - StudioWorkspaceEventBus workspaceBus, - AssetWorkspaceInteractionPort interactions) { - StudioControlLifecycleSupport.install(this, this); - this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); - this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); - this.interactions = Objects.requireNonNull(interactions, "interactions"); - setSpacing(12); - getStyleClass().add("assets-details-summary-actions-row"); - } - - @Override - public void subscribe() { - subscriptions.add(workspaceBus.subscribe(StudioAssetsDetailsViewStateChangedEvent.class, event -> { - if (projectReference.equals(event.project())) { - viewState = event.viewState(); - render(); - } - })); - } - - @Override - public void unsubscribe() { - subscriptions.clear(); - } - - private void render() { - getChildren().clear(); - if (viewState == null) { - return; - } - final AssetWorkspaceAssetSummary summary = viewState.workspaceState().selectedAsset().orElse(null); - if (summary == null) { - return; - } - - final VBox summarySection = createSummarySection(summary); - final VBox actionsSection = createActionsSection(summary); - HBox.setHgrow(summarySection, Priority.ALWAYS); - HBox.setHgrow(actionsSection, Priority.NEVER); - summarySection.setMaxWidth(Double.MAX_VALUE); - summarySection.setMinWidth(0); - actionsSection.setPrefWidth(280); - actionsSection.setMinWidth(240); - actionsSection.setMaxWidth(320); - getChildren().setAll(summarySection, actionsSection); - } - - private VBox createSummarySection(AssetWorkspaceAssetSummary summary) { - final VBox content = new VBox(8); - content.getChildren().addAll( - AssetWorkspaceDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_NAME), summary.assetName()), - AssetWorkspaceDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_REGISTRATION), AssetWorkspaceDetailsUiSupport.registrationLabel(summary.state())), - AssetWorkspaceDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_BUILD_PARTICIPATION), AssetWorkspaceDetailsUiSupport.buildParticipationLabel(summary.buildParticipation())), - AssetWorkspaceDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_ASSET_ID), summary.assetId() == null ? "—" : String.valueOf(summary.assetId())), - AssetWorkspaceDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_TYPE), summary.assetFamily()), - AssetWorkspaceDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), AssetWorkspaceDetailsUiSupport.projectRelativePath(projectReference, summary.assetRoot()))); - return AssetWorkspaceDetailsUiSupport.createSection(Container.i18n().text(I18n.ASSETS_SECTION_SUMMARY), content); - } - - private VBox createActionsSection(AssetWorkspaceAssetSummary summary) { - final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(summary); - final VBox content = new VBox(12); - for (AssetWorkspaceAction action : actionSet.primaryActions()) { - content.getChildren().add(createActionButton(action, false)); - } - for (AssetWorkspaceAction action : actionSet.sensitiveActions()) { - content.getChildren().add(createActionButton(action, true)); - } - if (viewState.stagedMutationPreview() != null - && viewState.stagedMutationPreview().asset().selectionKey().equals(summary.selectionKey())) { - content.getChildren().add(createStagedMutationPanel(viewState.stagedMutationPreview())); - } - return AssetWorkspaceDetailsUiSupport.createSection(Container.i18n().text(I18n.ASSETS_SECTION_ACTIONS), content); - } - - private Button createActionButton(AssetWorkspaceAction action, boolean sensitive) { - final Button button = new Button(AssetWorkspaceDetailsUiSupport.actionLabel(action)); - button.getStyleClass().addAll("studio-button", AssetWorkspaceDetailsUiSupport.actionButtonVariant(action, sensitive)); - button.setMaxWidth(Double.MAX_VALUE); - button.setOnAction(event -> interactions.requestMutationPreview(action)); - return button; - } - - private Node createStagedMutationPanel(AssetWorkspaceMutationPreview preview) { - final VBox panel = new VBox(10); - panel.getStyleClass().add("assets-mutation-panel"); - - final Label title = new Label(Container.i18n().format(I18n.ASSETS_MUTATION_PREVIEW_TITLE, AssetWorkspaceDetailsUiSupport.actionLabel(preview.action()))); - title.getStyleClass().add("assets-mutation-panel-title"); - panel.getChildren().add(title); - panel.getChildren().add(createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_AFFECTED_ASSET), - createAffectedAssetContent(preview))); - - final AssetWorkspaceMutationImpactViewModel impacts = AssetWorkspaceMutationImpactViewModel.from(preview); - panel.getChildren().add(createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_REGISTRY_IMPACT), - createMutationChangesContent(impacts.registryChanges(), Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_REGISTRY_IMPACT)))); - panel.getChildren().add(createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_WORKSPACE_IMPACT), - createMutationChangesContent(impacts.workspaceChanges(), Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_WORKSPACE_IMPACT)))); - panel.getChildren().add(createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_BLOCKERS), - createMutationMessages(preview.blockers(), "assets-mutation-message-blocker", Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_BLOCKERS)))); - panel.getChildren().add(createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_WARNINGS), - createMutationMessages(preview.warnings(), "assets-mutation-message-warning", Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_WARNINGS)))); - panel.getChildren().add(createMutationSection( - Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_SAFE_FIXES), - createMutationMessages(preview.safeFixes(), "assets-mutation-message-safe-fix", Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_SAFE_FIXES)))); - - final HBox actions = new HBox(8); - final Button cancel = new Button(Container.i18n().text(I18n.ASSETS_MUTATION_CANCEL)); - cancel.getStyleClass().addAll("studio-button", "studio-button-cancel"); - cancel.setOnAction(event -> interactions.cancelStagedMutationPreview()); - final Button apply = new Button(Container.i18n().text(I18n.ASSETS_MUTATION_APPLY)); - apply.getStyleClass().addAll("studio-button", "studio-button-primary"); - apply.setDisable(!preview.canApply()); - apply.setOnAction(event -> interactions.applyStagedMutation(preview)); - actions.getChildren().addAll(cancel, apply); - panel.getChildren().add(actions); - - return panel; - } - - 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 preview) { - final VBox box = new VBox(6); - box.getChildren().add(AssetWorkspaceDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_NAME), preview.asset().assetName())); - box.getChildren().add(AssetWorkspaceDetailsUiSupport.createKeyValueRow( - Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), - AssetWorkspaceDetailsUiSupport.projectRelativePath(projectReference, preview.asset().assetRoot()))); - if (preview.targetAssetRoot() != null) { - box.getChildren().add(AssetWorkspaceDetailsUiSupport.createKeyValueRow( - Container.i18n().text(I18n.ASSETS_LABEL_TARGET_LOCATION), - AssetWorkspaceDetailsUiSupport.projectRelativePath(projectReference, preview.targetAssetRoot()))); - } - return box; - } - - private Node createMutationChangesContent(List changes, String emptyText) { - if (changes.isEmpty()) { - return AssetWorkspaceDetailsUiSupport.createSectionMessage(emptyText); - } - 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(List messages, String styleClass, String emptyText) { - if (messages.isEmpty()) { - return AssetWorkspaceDetailsUiSupport.createSectionMessage(emptyText); - } - 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; - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationService.java deleted file mode 100644 index 4f86a97e..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationService.java +++ /dev/null @@ -1,305 +0,0 @@ -package p.studio.workspaces.assets; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.ObjectMapper; -import p.studio.projects.ProjectReference; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; -import java.util.stream.Stream; - -public final class FileSystemAssetWorkspaceMutationService implements AssetWorkspaceMutationService { - private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final String PROMETEU_DIR = ".prometeu"; - - @Override - public AssetWorkspaceMutationPreview preview( - ProjectReference projectReference, - AssetWorkspaceAssetSummary asset, - AssetWorkspaceAction action, - Path targetRoot) { - Objects.requireNonNull(projectReference, "projectReference"); - Objects.requireNonNull(asset, "asset"); - Objects.requireNonNull(action, "action"); - - final List blockers = new ArrayList<>(); - final List warnings = new ArrayList<>(); - final List safeFixes = new ArrayList<>(); - final List changes = new ArrayList<>(); - final Path assetsRoot = projectReference.rootPath().resolve("assets"); - final String relativeRoot = relativeAssetRoot(asset.assetRoot(), assetsRoot); - Path targetAssetRoot = null; - - if (!Files.exists(asset.assetRoot())) { - blockers.add("Asset root does not exist: " + asset.assetRoot()); - } - - switch (action) { - case REGISTER -> { - if (asset.state() == AssetWorkspaceAssetState.REGISTERED) { - blockers.add("Asset is already registered."); - } else { - changes.add(new AssetWorkspaceMutationChange( - AssetWorkspaceMutationChangeScope.REGISTRY, - "ADD", - relativeRoot)); - if (asset.hasDiagnostics()) { - warnings.add("Asset currently reports diagnostics and will still be registered."); - } - } - } - case INCLUDE_IN_BUILD -> { - if (asset.state() != AssetWorkspaceAssetState.REGISTERED) { - blockers.add("Only registered assets can be included in builds."); - } else if (asset.buildParticipation() == AssetWorkspaceBuildParticipation.INCLUDED) { - blockers.add("Asset is already included in builds."); - } else { - changes.add(new AssetWorkspaceMutationChange( - AssetWorkspaceMutationChangeScope.REGISTRY, - "UPDATE", - relativeRoot)); - warnings.add("The asset will remain registered and return to the build set."); - } - } - case EXCLUDE_FROM_BUILD -> { - if (asset.state() != AssetWorkspaceAssetState.REGISTERED) { - blockers.add("Only registered assets can be excluded from builds."); - } else if (asset.buildParticipation() == AssetWorkspaceBuildParticipation.EXCLUDED) { - blockers.add("Asset is already excluded from builds."); - } else { - changes.add(new AssetWorkspaceMutationChange( - AssetWorkspaceMutationChangeScope.REGISTRY, - "UPDATE", - relativeRoot)); - warnings.add("The asset will remain registered but leave the build set."); - } - } - case RELOCATE -> { - targetAssetRoot = targetRoot == null - ? relocationTarget(asset, assetsRoot) - : targetRoot.toAbsolutePath().normalize(); - final AssetRootValidationResult validation = AssetRelocationTargetValidator.validate( - projectReference, - asset.assetRoot(), - targetAssetRoot); - if (!validation.valid()) { - blockers.add(validation.message()); - } else { - targetAssetRoot = validation.assetRoot(); - final String targetRelativeRoot = validation.normalizedRelativeRoot(); - if (asset.state() == AssetWorkspaceAssetState.REGISTERED) { - changes.add(new AssetWorkspaceMutationChange( - AssetWorkspaceMutationChangeScope.REGISTRY, - "UPDATE", - relativeRoot + " -> " + targetRelativeRoot)); - } - changes.add(new AssetWorkspaceMutationChange( - AssetWorkspaceMutationChangeScope.WORKSPACE, - "MOVE", - relativeRoot + " -> " + targetRelativeRoot)); - warnings.add("Relocation preserves asset identity, but it changes the root path seen by the workspace."); - } - } - case REMOVE -> { - if (asset.state() == AssetWorkspaceAssetState.REGISTERED) { - changes.add(new AssetWorkspaceMutationChange( - AssetWorkspaceMutationChangeScope.REGISTRY, - "REMOVE", - relativeRoot)); - } - changes.add(new AssetWorkspaceMutationChange( - AssetWorkspaceMutationChangeScope.WORKSPACE, - "DELETE", - relativeRoot)); - warnings.add("Physical files inside the asset root will be deleted."); - } - } - - final boolean highRisk = action == AssetWorkspaceAction.REMOVE || action == AssetWorkspaceAction.RELOCATE; - return new AssetWorkspaceMutationPreview(action, asset, blockers, warnings, safeFixes, changes, highRisk, targetAssetRoot); - } - - @Override - public void apply(ProjectReference projectReference, AssetWorkspaceMutationPreview preview) { - Objects.requireNonNull(projectReference, "projectReference"); - Objects.requireNonNull(preview, "preview"); - if (!preview.canApply()) { - throw new IllegalStateException("Cannot apply mutation preview with blockers"); - } - - final Path assetsRoot = projectReference.rootPath().resolve("assets"); - final Registry registry = loadRegistry(assetsRoot); - final Path assetRoot = preview.asset().assetRoot(); - - switch (preview.action()) { - case REGISTER -> { - if (registryContainsRoot(registry, assetRoot, assetsRoot)) { - return; - } - final RegistryEntry entry = new RegistryEntry(); - entry.assetId = registry.nextAssetId <= 0 ? 1 : registry.nextAssetId; - entry.assetUuid = UUID.randomUUID().toString(); - entry.root = relativeAssetRoot(assetRoot, assetsRoot); - registry.assets.add(entry); - registry.assets.sort(Comparator.comparingInt(value -> value.assetId)); - registry.nextAssetId = entry.assetId + 1; - writeRegistry(assetsRoot, registry); - } - case INCLUDE_IN_BUILD, EXCLUDE_FROM_BUILD -> { - final boolean includedInBuild = preview.action() == AssetWorkspaceAction.INCLUDE_IN_BUILD; - registry.assets.stream() - .filter(entry -> entry.root != null && assetsRoot.resolve(entry.root).toAbsolutePath().normalize().equals(assetRoot)) - .findFirst() - .ifPresent(entry -> entry.includedInBuild = includedInBuild); - writeRegistry(assetsRoot, registry); - } - case RELOCATE -> { - final Path targetAssetRoot = requireTargetAssetRoot(preview); - if (preview.asset().state() == AssetWorkspaceAssetState.REGISTERED) { - registry.assets.stream() - .filter(entry -> entry.root != null && assetsRoot.resolve(entry.root).toAbsolutePath().normalize().equals(assetRoot)) - .findFirst() - .ifPresent(entry -> entry.root = relativeAssetRoot(targetAssetRoot, assetsRoot)); - writeRegistry(assetsRoot, registry); - } - moveAssetRoot(assetRoot, targetAssetRoot); - } - case REMOVE -> { - registry.assets.removeIf(entry -> entry.root != null && assetsRoot.resolve(entry.root).toAbsolutePath().normalize().equals(assetRoot)); - writeRegistry(assetsRoot, registry); - deleteRecursively(assetRoot); - } - } - } - - private Registry loadRegistry(Path assetsRoot) { - final Path registryPath = assetsRoot.resolve(PROMETEU_DIR).resolve("index.json"); - if (!Files.isRegularFile(registryPath)) { - final Registry registry = new Registry(); - registry.schemaVersion = 1; - registry.nextAssetId = 1; - registry.assets = new ArrayList<>(); - return registry; - } - try { - final Registry registry = MAPPER.readValue(registryPath.toFile(), Registry.class); - if (registry.assets == null) { - registry.assets = new ArrayList<>(); - } - if (registry.schemaVersion <= 0) { - registry.schemaVersion = 1; - } - if (registry.nextAssetId <= 0) { - registry.nextAssetId = registry.assets.stream().mapToInt(entry -> entry.assetId).max().orElse(0) + 1; - } - return registry; - } catch (IOException ioException) { - throw new UncheckedIOException(ioException); - } - } - - private void writeRegistry(Path assetsRoot, Registry registry) { - try { - final Path registryDir = assetsRoot.resolve(PROMETEU_DIR); - Files.createDirectories(registryDir); - MAPPER.writerWithDefaultPrettyPrinter().writeValue(registryDir.resolve("index.json").toFile(), registry); - } catch (IOException ioException) { - throw new UncheckedIOException(ioException); - } - } - - private boolean registryContainsRoot(Registry registry, Path assetRoot, Path assetsRoot) { - return registry.assets.stream() - .filter(entry -> entry.root != null) - .map(entry -> assetsRoot.resolve(entry.root).toAbsolutePath().normalize()) - .anyMatch(assetRoot::equals); - } - - private String relativeAssetRoot(Path assetRoot, Path assetsRoot) { - return assetsRoot.relativize(assetRoot.toAbsolutePath().normalize()).toString().replace('\\', '/'); - } - - private Path relocationTarget(AssetWorkspaceAssetSummary asset, Path assetsRoot) { - final Path assetRoot = asset.assetRoot(); - final Path siblingParent = assetRoot.getParent() == null ? assetsRoot : assetRoot.getParent(); - return nextAvailableSibling(siblingParent, assetRoot.getFileName().toString() + "-relocated"); - } - - private Path nextAvailableSibling(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 requireTargetAssetRoot(AssetWorkspaceMutationPreview preview) { - if (preview.targetAssetRoot() == null) { - throw new IllegalStateException("Mutation preview does not define a target asset root"); - } - return preview.targetAssetRoot(); - } - - private void moveAssetRoot(Path sourceRoot, Path targetRoot) { - if (sourceRoot.equals(targetRoot)) { - return; - } - try { - Files.createDirectories(Objects.requireNonNull(targetRoot, "targetRoot").getParent()); - Files.move(sourceRoot, targetRoot); - } catch (IOException ioException) { - throw new UncheckedIOException(ioException); - } - } - - private void deleteRecursively(Path root) { - if (!Files.exists(root)) { - return; - } - try (Stream stream = Files.walk(root)) { - stream.sorted(Comparator.reverseOrder()).forEach(path -> { - try { - Files.deleteIfExists(path); - } catch (IOException ioException) { - throw new UncheckedIOException(ioException); - } - }); - } catch (IOException ioException) { - throw new UncheckedIOException(ioException); - } - } - - @JsonIgnoreProperties(ignoreUnknown = true) - private static final class Registry { - @JsonProperty("schema_version") - public int schemaVersion; - - @JsonProperty("next_asset_id") - public int nextAssetId; - - @JsonProperty("assets") - public List assets = new ArrayList<>(); - } - - @JsonIgnoreProperties(ignoreUnknown = true) - private static final class RegistryEntry { - @JsonProperty("asset_id") - public int assetId; - - @JsonProperty("asset_uuid") - public String assetUuid; - - @JsonProperty("root") - public String root; - - @JsonProperty("included_in_build") - public boolean includedInBuild = true; - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceService.java deleted file mode 100644 index 34cfeea1..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceService.java +++ /dev/null @@ -1,204 +0,0 @@ -package p.studio.workspaces.assets; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import p.studio.projects.ProjectReference; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; -import java.util.stream.Stream; - -public final class FileSystemAssetWorkspaceService implements AssetWorkspaceService { - private static final ObjectMapper MAPPER = new ObjectMapper(); - - @Override - public AssetWorkspaceSnapshot loadWorkspace(ProjectReference projectReference) { - final Path projectRoot = Objects.requireNonNull(projectReference, "projectReference").rootPath(); - final Path assetsRoot = projectRoot.resolve("assets"); - if (!Files.isDirectory(assetsRoot)) { - return new AssetWorkspaceSnapshot(List.of()); - } - - final Map registryByRoot = readRegistry(assetsRoot); - try (Stream paths = Files.find(assetsRoot, Integer.MAX_VALUE, (path, attrs) -> - attrs.isRegularFile() && path.getFileName().toString().equals("asset.json"))) { - final List assets = paths - .map(assetManifestPath -> buildAssetSummary(assetManifestPath, registryByRoot)) - .sorted(Comparator - .comparing((AssetWorkspaceAssetSummary asset) -> asset.assetRoot().toString(), String.CASE_INSENSITIVE_ORDER) - .thenComparing(AssetWorkspaceAssetSummary::assetName, String.CASE_INSENSITIVE_ORDER)) - .toList(); - return new AssetWorkspaceSnapshot(assets); - } catch (IOException ioException) { - throw new UncheckedIOException(ioException); - } - } - - @Override - public AssetWorkspaceAssetDetails loadAssetDetails(ProjectReference projectReference, AssetWorkspaceSelectionKey selectionKey) { - final Path projectRoot = Objects.requireNonNull(projectReference, "projectReference").rootPath(); - final Path assetsRoot = projectRoot.resolve("assets"); - final Map registryByRoot = readRegistry(assetsRoot); - final Path assetRoot = resolveAssetRoot(selectionKey, assetsRoot, registryByRoot); - final Path assetManifestPath = assetRoot.resolve("asset.json"); - try { - final JsonNode root = MAPPER.readTree(assetManifestPath.toFile()); - final AssetWorkspaceAssetSummary summary = buildAssetSummary(assetManifestPath, registryByRoot); - final String outputFormat = readText(root.path("output").path("format"), "unknown"); - final String outputCodec = readText(root.path("output").path("codec"), "unknown"); - final Map> inputsByRole = readInputs(root.path("inputs"), assetRoot); - return new AssetWorkspaceAssetDetails(summary, outputFormat, outputCodec, inputsByRole, List.of()); - } catch (IOException ioException) { - final AssetWorkspaceAssetSummary summary = buildAssetSummary(assetManifestPath, registryByRoot); - return new AssetWorkspaceAssetDetails( - summary, - "unknown", - "unknown", - Map.of(), - List.of(new AssetWorkspaceDiagnostic( - AssetWorkspaceDiagnosticSeverity.BLOCKER, - "Unable to read asset.json for the selected asset."))); - } - } - - private AssetWorkspaceAssetSummary buildAssetSummary(Path assetManifestPath, Map registryByRoot) { - final Path assetRoot = assetManifestPath.getParent().toAbsolutePath().normalize(); - try { - final AssetManifest manifest = MAPPER.readValue(assetManifestPath.toFile(), AssetManifest.class); - final RegistryEntry registryEntry = registryByRoot.get(assetRoot); - final Integer assetId = registryEntry == null ? null : registryEntry.assetId(); - final AssetWorkspaceAssetState state = assetId == null - ? AssetWorkspaceAssetState.UNREGISTERED - : AssetWorkspaceAssetState.REGISTERED; - final AssetWorkspaceSelectionKey selectionKey = assetId == null - ? new AssetWorkspaceSelectionKey.OrphanAsset(assetRoot) - : new AssetWorkspaceSelectionKey.ManagedAsset(assetId); - final String assetName = Optional.ofNullable(manifest.name()) - .filter(name -> !name.isBlank()) - .orElse(assetRoot.getFileName().toString()); - final String assetFamily = Optional.ofNullable(manifest.type()) - .filter(type -> !type.isBlank()) - .orElse("unknown"); - final boolean preload = manifest.preload() != null && Boolean.TRUE.equals(manifest.preload().enabled()); - return new AssetWorkspaceAssetSummary( - selectionKey, - assetName, - state, - state == AssetWorkspaceAssetState.REGISTERED - ? (registryEntry.includedInBuild() ? AssetWorkspaceBuildParticipation.INCLUDED : AssetWorkspaceBuildParticipation.EXCLUDED) - : AssetWorkspaceBuildParticipation.EXCLUDED, - assetId, - assetFamily, - assetRoot, - preload, - false); - } catch (IOException ioException) { - final AssetWorkspaceSelectionKey selectionKey = new AssetWorkspaceSelectionKey.OrphanAsset(assetRoot); - return new AssetWorkspaceAssetSummary( - selectionKey, - assetRoot.getFileName().toString(), - AssetWorkspaceAssetState.UNREGISTERED, - AssetWorkspaceBuildParticipation.EXCLUDED, - null, - "unknown", - assetRoot, - false, - true); - } - } - - private Map readRegistry(Path assetsRoot) { - final Path registryPath = assetsRoot.resolve(".prometeu").resolve("index.json"); - if (!Files.isRegularFile(registryPath)) { - return Map.of(); - } - try { - final Registry registry = MAPPER.readValue(registryPath.toFile(), Registry.class); - if (registry.assets() == null || registry.assets().isEmpty()) { - return Map.of(); - } - - final Map registryByRoot = new HashMap<>(); - for (RegistryEntry entry : registry.assets()) { - if (entry == null || entry.root() == null || entry.root().isBlank() || entry.assetId() == null) { - continue; - } - registryByRoot.put(assetsRoot.resolve(entry.root()).toAbsolutePath().normalize(), entry); - } - return Map.copyOf(registryByRoot); - } catch (IOException ioException) { - throw new UncheckedIOException(ioException); - } - } - - private Path resolveAssetRoot( - AssetWorkspaceSelectionKey selectionKey, - Path assetsRoot, - Map registryByRoot) { - return switch (selectionKey) { - case AssetWorkspaceSelectionKey.ManagedAsset managedAsset -> registryByRoot.entrySet().stream() - .filter(entry -> managedAsset.assetId() == entry.getValue().assetId()) - .map(Map.Entry::getKey) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("managed asset root not found for assetId " + managedAsset.assetId())); - case AssetWorkspaceSelectionKey.OrphanAsset orphanAsset -> orphanAsset.assetRoot(); - }; - } - - private Map> readInputs(JsonNode inputsNode, Path assetRoot) { - if (inputsNode == null || !inputsNode.isObject()) { - return Map.of(); - } - final Map> result = new LinkedHashMap<>(); - final Iterator> fields = inputsNode.fields(); - while (fields.hasNext()) { - final Map.Entry entry = fields.next(); - if (!entry.getValue().isArray()) { - continue; - } - final List resolvedInputs = new ArrayList<>(); - for (JsonNode inputNode : entry.getValue()) { - if (!inputNode.isTextual()) { - continue; - } - resolvedInputs.add(assetRoot.resolve(inputNode.asText()).toAbsolutePath().normalize()); - } - result.put(entry.getKey(), List.copyOf(resolvedInputs)); - } - return Map.copyOf(result); - } - - private String readText(JsonNode node, String fallback) { - if (node == null || !node.isTextual()) { - return fallback; - } - final String text = node.asText(); - return text == null || text.isBlank() ? fallback : text; - } - - @JsonIgnoreProperties(ignoreUnknown = true) - private record AssetManifest(String name, String type, Preload preload) { - } - - @JsonIgnoreProperties(ignoreUnknown = true) - private record Preload(Boolean enabled) { - } - - @JsonIgnoreProperties(ignoreUnknown = true) - private record Registry(List assets) { - } - - @JsonIgnoreProperties(ignoreUnknown = true) - private record RegistryEntry(Integer assetId, String root, boolean includedInBuild) { - private RegistryEntry( - @com.fasterxml.jackson.annotation.JsonProperty("asset_id") Integer assetId, - @com.fasterxml.jackson.annotation.JsonProperty("root") String root, - @com.fasterxml.jackson.annotation.JsonProperty("included_in_build") Boolean includedInBuild) { - this(assetId, root, includedInBuild == null || includedInBuild); - } - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetCreationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetCreationService.java deleted file mode 100644 index f5b600c4..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetCreationService.java +++ /dev/null @@ -1,84 +0,0 @@ -package p.studio.workspaces.assets; - -import com.fasterxml.jackson.databind.ObjectMapper; -import p.packer.api.PackerProjectContext; -import p.packer.api.workspace.InitWorkspaceRequest; -import p.packer.foundation.PackerRegistryState; -import p.packer.foundation.PackerWorkspaceFoundation; -import p.studio.projects.ProjectReference; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; - -public final class PackerBackedAssetCreationService implements AssetCreationService { - private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final String ASSET_MANIFEST = "asset.json"; - - private final PackerWorkspaceFoundation workspaceFoundation; - - public PackerBackedAssetCreationService() { - this(new PackerWorkspaceFoundation()); - } - - PackerBackedAssetCreationService(PackerWorkspaceFoundation workspaceFoundation) { - this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation"); - } - - @Override - public AssetCreationResult create(ProjectReference projectReference, AssetCreationRequest request) { - final ProjectReference reference = Objects.requireNonNull(projectReference, "projectReference"); - final AssetCreationRequest creation = Objects.requireNonNull(request, "request"); - final PackerProjectContext project = new PackerProjectContext(reference.name(), reference.rootPath()); - workspaceFoundation.initWorkspace(new InitWorkspaceRequest(project)); - - final AssetRootValidationResult rootValidation = AssetRootValidator.validate(reference, creation.relativeRoot()); - if (!rootValidation.valid()) { - throw new IllegalArgumentException(rootValidation.message()); - } - if (!AssetCreationCatalog.supports(creation.assetType(), creation.outputFormat(), creation.outputCodec())) { - throw new IllegalArgumentException("Selected asset type, output format, and codec combination is not supported."); - } - final Path assetRoot = rootValidation.assetRoot(); - final Path manifestPath = assetRoot.resolve(ASSET_MANIFEST); - final PackerRegistryState registry = workspaceFoundation.loadRegistry(project); - if (workspaceFoundation.lookup().findByRoot(project, registry, assetRoot).isPresent()) { - throw new IllegalArgumentException("Asset root is already registered in the project: " + rootValidation.normalizedRelativeRoot()); - } - if (Files.isRegularFile(manifestPath)) { - throw new IllegalArgumentException("asset.json already exists at: " + rootValidation.normalizedRelativeRoot()); - } - - try { - Files.createDirectories(assetRoot); - MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifestDocument(creation)); - } catch (IOException exception) { - throw new IllegalStateException("Unable to create asset files: " + exception.getMessage(), exception); - } - - final var entry = workspaceFoundation.allocateIdentity(project, registry, assetRoot); - workspaceFoundation.saveRegistry(project, workspaceFoundation.appendAllocatedEntry(registry, entry)); - return new AssetCreationResult(new AssetWorkspaceSelectionKey.ManagedAsset(entry.assetId()), assetRoot); - } - - private Map manifestDocument(AssetCreationRequest request) { - final Map root = new LinkedHashMap<>(); - root.put("schema_version", 1); - root.put("name", request.assetName()); - root.put("type", request.assetType()); - root.put("inputs", Map.of()); - - final Map output = new LinkedHashMap<>(); - output.put("format", request.outputFormat()); - output.put("codec", request.outputCodec()); - root.put("output", output); - - final Map preload = new LinkedHashMap<>(); - preload.put("enabled", request.preloadEnabled()); - root.put("preload", preload); - return root; - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationService.java deleted file mode 100644 index c265191e..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationService.java +++ /dev/null @@ -1,199 +0,0 @@ -package p.studio.workspaces.assets; - -import p.packer.api.PackerOperationClass; -import p.packer.api.PackerProjectContext; -import p.packer.api.events.PackerEvent; -import p.packer.api.events.PackerEventKind; -import p.packer.api.mutations.PackerMutationPreview; -import p.packer.api.mutations.PackerMutationRequest; -import p.packer.api.mutations.PackerMutationType; -import p.packer.api.mutations.PackerProposedAction; -import p.packer.declarations.PackerAssetDetailsService; -import p.packer.foundation.PackerWorkspaceFoundation; -import p.packer.mutations.FileSystemPackerMutationService; -import p.packer.mutations.PackerProjectWriteCoordinator; -import p.studio.events.StudioAssetsMutationAppliedEvent; -import p.studio.events.StudioAssetsMutationFailedEvent; -import p.studio.events.StudioAssetsMutationPreviewReadyEvent; -import p.studio.events.StudioWorkspaceEventBus; -import p.studio.projects.ProjectReference; - -import java.nio.file.Path; -import java.util.List; -import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; - -public final class PackerBackedAssetWorkspaceMutationService implements AssetWorkspaceMutationService { - private final FileSystemPackerMutationService packerMutationService; - private final StudioWorkspaceEventBus eventBus; - private final ConcurrentMap previewSessions = new ConcurrentHashMap<>(); - private final ConcurrentMap operationSessions = new ConcurrentHashMap<>(); - - public PackerBackedAssetWorkspaceMutationService(StudioWorkspaceEventBus eventBus) { - this(eventBus, new PackerWorkspaceFoundation(), new PackerAssetDetailsService(), new PackerProjectWriteCoordinator()); - } - - PackerBackedAssetWorkspaceMutationService( - StudioWorkspaceEventBus eventBus, - PackerWorkspaceFoundation workspaceFoundation, - PackerAssetDetailsService detailsService, - PackerProjectWriteCoordinator writeCoordinator) { - this.eventBus = Objects.requireNonNull(eventBus, "eventBus"); - this.packerMutationService = new FileSystemPackerMutationService( - Objects.requireNonNull(workspaceFoundation, "workspaceFoundation"), - Objects.requireNonNull(detailsService, "detailsService"), - Objects.requireNonNull(writeCoordinator, "writeCoordinator"), - this::forwardLifecycleEvent); - } - - @Override - public AssetWorkspaceMutationPreview preview( - ProjectReference projectReference, - AssetWorkspaceAssetSummary asset, - AssetWorkspaceAction action, - Path targetRoot) { - Objects.requireNonNull(projectReference, "projectReference"); - Objects.requireNonNull(asset, "asset"); - final Path validatedTargetRoot = validateTargetRoot(projectReference, asset, action, targetRoot); - if (action == AssetWorkspaceAction.RELOCATE && targetRoot != null && validatedTargetRoot == null) { - final AssetRootValidationResult validation = AssetRelocationTargetValidator.validate(projectReference, asset.assetRoot(), targetRoot); - final AssetWorkspaceMutationPreview preview = new AssetWorkspaceMutationPreview( - action, - asset, - List.of(validation.message()), - List.of(), - List.of(), - List.of(), - true, - targetRoot); - eventBus.publish(new StudioAssetsMutationPreviewReadyEvent(projectReference, action, 1)); - return preview; - } - final PackerMutationPreview packerPreview = packerMutationService.preview( - new PackerMutationRequest( - project(projectReference), - mutationType(action), - assetReference(projectReference, asset), - validatedTargetRoot)); - final AssetWorkspaceMutationPreview studioPreview = mapPreview(action, asset, packerPreview); - final OperationSession session = new OperationSession(projectReference, action, studioPreview, packerPreview); - previewSessions.put(studioPreview, session); - operationSessions.put(packerPreview.operationId(), session); - eventBus.publish(new StudioAssetsMutationPreviewReadyEvent(projectReference, action, 1)); - return studioPreview; - } - - @Override - public void apply(ProjectReference projectReference, AssetWorkspaceMutationPreview preview) { - Objects.requireNonNull(projectReference, "projectReference"); - final OperationSession session = previewSessions.get(Objects.requireNonNull(preview, "preview")); - if (session == null) { - throw new IllegalStateException("Mutation preview is not backed by a packer preview."); - } - try { - packerMutationService.apply(session.packerPreview()); - } catch (RuntimeException exception) { - throw exception; - } - } - - void forwardLifecycleEvent(PackerEvent event) { - final OperationSession session = operationSessions.get(Objects.requireNonNull(event, "event").operationId()); - if (session == null) { - return; - } - if (event.kind() == PackerEventKind.ACTION_APPLIED) { - previewSessions.remove(session.studioPreview()); - operationSessions.remove(event.operationId()); - eventBus.publish(new StudioAssetsMutationAppliedEvent( - session.projectReference(), - session.action(), - affectedAssetCount(event))); - return; - } - if (event.kind() == PackerEventKind.ACTION_FAILED) { - eventBus.publish(new StudioAssetsMutationFailedEvent( - session.projectReference(), - session.action(), - event.summary())); - } - } - - private AssetWorkspaceMutationPreview mapPreview( - AssetWorkspaceAction action, - AssetWorkspaceAssetSummary asset, - PackerMutationPreview preview) { - final List changes = preview.proposedActions().stream() - .map(this::mapChange) - .toList(); - return new AssetWorkspaceMutationPreview( - action, - asset, - preview.blockers(), - preview.warnings(), - preview.safeFixes(), - changes, - preview.highRisk(), - preview.targetAssetRoot()); - } - - private AssetWorkspaceMutationChange mapChange(PackerProposedAction action) { - return new AssetWorkspaceMutationChange( - scope(action.operationClass()), - action.verb(), - action.target()); - } - - private AssetWorkspaceMutationChangeScope scope(PackerOperationClass operationClass) { - return switch (operationClass) { - case REGISTRY_MUTATION -> AssetWorkspaceMutationChangeScope.REGISTRY; - case WORKSPACE_MUTATION, READ_ONLY -> AssetWorkspaceMutationChangeScope.WORKSPACE; - }; - } - - private PackerMutationType mutationType(AssetWorkspaceAction action) { - return switch (Objects.requireNonNull(action, "action")) { - case REGISTER -> PackerMutationType.REGISTER_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 REMOVE -> PackerMutationType.REMOVE_ASSET; - }; - } - - private PackerProjectContext project(ProjectReference projectReference) { - return new PackerProjectContext(projectReference.name(), projectReference.rootPath()); - } - - private String assetReference(ProjectReference projectReference, AssetWorkspaceAssetSummary asset) { - if (asset.assetId() != null) { - return Integer.toString(asset.assetId()); - } - final Path assetsRoot = projectReference.rootPath().resolve("assets").toAbsolutePath().normalize(); - return assetsRoot.relativize(asset.assetRoot().toAbsolutePath().normalize()).toString().replace('\\', '/'); - } - - private int affectedAssetCount(PackerEvent event) { - return event.affectedAssets().isEmpty() ? 1 : event.affectedAssets().size(); - } - - private Path validateTargetRoot( - ProjectReference projectReference, - AssetWorkspaceAssetSummary asset, - AssetWorkspaceAction action, - Path targetRoot) { - if (targetRoot == null || action != AssetWorkspaceAction.RELOCATE) { - return targetRoot; - } - final AssetRootValidationResult validation = AssetRelocationTargetValidator.validate(projectReference, asset.assetRoot(), targetRoot); - return validation.valid() ? validation.assetRoot() : null; - } - - private record OperationSession( - ProjectReference projectReference, - AssetWorkspaceAction action, - AssetWorkspaceMutationPreview studioPreview, - PackerMutationPreview packerPreview) { - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceService.java deleted file mode 100644 index 441eef2a..00000000 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceService.java +++ /dev/null @@ -1,110 +0,0 @@ -package p.studio.workspaces.assets; - -import p.packer.api.PackerProjectContext; -import p.packer.api.assets.PackerBuildParticipation; -import p.packer.api.assets.PackerAssetDetails; -import p.packer.api.assets.PackerAssetState; -import p.packer.api.assets.PackerAssetSummary; -import p.packer.api.diagnostics.PackerDiagnostic; -import p.packer.api.diagnostics.PackerDiagnosticSeverity; -import p.packer.api.workspace.GetAssetDetailsRequest; -import p.packer.api.workspace.ListAssetsRequest; -import p.packer.workspace.FileSystemPackerWorkspaceService; -import p.studio.projects.ProjectReference; - -import java.nio.file.Path; -import java.util.List; -import java.util.Objects; - -public final class PackerBackedAssetWorkspaceService implements AssetWorkspaceService { - private final FileSystemPackerWorkspaceService packerWorkspaceService; - - public PackerBackedAssetWorkspaceService() { - this(new FileSystemPackerWorkspaceService()); - } - - public PackerBackedAssetWorkspaceService(FileSystemPackerWorkspaceService packerWorkspaceService) { - this.packerWorkspaceService = Objects.requireNonNull(packerWorkspaceService, "packerWorkspaceService"); - } - - @Override - public AssetWorkspaceSnapshot loadWorkspace(ProjectReference projectReference) { - final var result = packerWorkspaceService.listAssets(new ListAssetsRequest(project(projectReference))); - final List assets = result.assets().stream() - .map(this::mapSummary) - .toList(); - return new AssetWorkspaceSnapshot(assets); - } - - @Override - public AssetWorkspaceAssetDetails loadAssetDetails(ProjectReference projectReference, AssetWorkspaceSelectionKey selectionKey) { - final String assetReference = switch (selectionKey) { - case AssetWorkspaceSelectionKey.ManagedAsset managedAsset -> Integer.toString(managedAsset.assetId()); - case AssetWorkspaceSelectionKey.OrphanAsset orphanAsset -> relativeAssetRoot(projectReference, orphanAsset.assetRoot()); - }; - final var result = packerWorkspaceService.getAssetDetails(new GetAssetDetailsRequest(project(projectReference), assetReference)); - return mapDetails(result.details()); - } - - private AssetWorkspaceAssetSummary mapSummary(PackerAssetSummary summary) { - final AssetWorkspaceAssetState state = toStudioState(summary); - final AssetWorkspaceSelectionKey selectionKey = state == AssetWorkspaceAssetState.REGISTERED - ? new AssetWorkspaceSelectionKey.ManagedAsset(summary.identity().assetId()) - : new AssetWorkspaceSelectionKey.OrphanAsset(summary.identity().assetRoot()); - return new AssetWorkspaceAssetSummary( - selectionKey, - summary.identity().assetName(), - state, - toBuildParticipation(summary.buildParticipation()), - summary.identity().assetId(), - summary.assetFamily(), - summary.identity().assetRoot(), - summary.preloadEnabled(), - summary.hasDiagnostics()); - } - - private AssetWorkspaceAssetDetails mapDetails(PackerAssetDetails details) { - return new AssetWorkspaceAssetDetails( - mapSummary(details.summary()), - details.outputFormat(), - details.outputCodec(), - details.inputsByRole(), - details.diagnostics().stream().map(this::mapDiagnostic).toList()); - } - - private AssetWorkspaceDiagnostic mapDiagnostic(PackerDiagnostic diagnostic) { - return new AssetWorkspaceDiagnostic( - switch (diagnostic.severity()) { - case ERROR -> AssetWorkspaceDiagnosticSeverity.BLOCKER; - case WARNING -> AssetWorkspaceDiagnosticSeverity.WARNING; - case INFO -> AssetWorkspaceDiagnosticSeverity.HINT; - }, - diagnostic.message()); - } - - private AssetWorkspaceAssetState toStudioState(PackerAssetSummary summary) { - if (summary.state() == PackerAssetState.REGISTERED) { - return AssetWorkspaceAssetState.REGISTERED; - } - return AssetWorkspaceAssetState.UNREGISTERED; - } - - private AssetWorkspaceBuildParticipation toBuildParticipation(PackerBuildParticipation buildParticipation) { - return switch (buildParticipation) { - case INCLUDED -> AssetWorkspaceBuildParticipation.INCLUDED; - case EXCLUDED -> AssetWorkspaceBuildParticipation.EXCLUDED; - }; - } - - private PackerProjectContext project(ProjectReference projectReference) { - final ProjectReference reference = Objects.requireNonNull(projectReference, "projectReference"); - return new PackerProjectContext(reference.name(), reference.rootPath()); - } - - private String relativeAssetRoot(ProjectReference projectReference, Path assetRoot) { - return projectReference.rootPath().resolve("assets").toAbsolutePath().normalize() - .relativize(assetRoot.toAbsolutePath().normalize()) - .toString() - .replace('\\', '/'); - } -} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java new file mode 100644 index 00000000..7e0cc970 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java @@ -0,0 +1,256 @@ +package p.studio.workspaces.assets.details; + +import javafx.application.Platform; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import p.packer.assets.AssetReference; +import p.packer.assets.PackerAssetDetails; +import p.packer.messages.GetAssetDetailsRequest; +import p.studio.Container; +import p.studio.events.StudioWorkspaceEventBus; +import p.studio.projects.ProjectReference; +import p.studio.utilities.i18n.I18n; +import p.studio.workspaces.assets.details.contract.AssetDetailsContractControl; +import p.studio.workspaces.assets.details.summary.AssetDetailsSummaryControl; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails; +import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsStatus; +import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState; +import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceSelectionRequestedEvent; +import p.studio.workspaces.framework.StudioEventAware; +import p.studio.workspaces.framework.StudioEventBindings; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; + +public final class AssetDetailsControl extends VBox implements StudioEventAware { + private final ProjectReference projectReference; + private final StudioWorkspaceEventBus workspaceBus; + private final StudioEventBindings eventBindings = new StudioEventBindings(); + private final Label detailsTitleLabel = new Label(); + private final Label workspaceSummaryLabel = new Label(); + private final VBox detailsContent = new VBox(12); + private final ScrollPane detailsScroll = new ScrollPane(); + private final AssetDetailsSummaryControl summaryControl; + private final AssetDetailsContractControl contractControl; + private final VBox actionsContent = new VBox(10); + private final ScrollPane actionsScroll = new ScrollPane(); + private final VBox actionsSection; + private final HBox primarySectionsRow = new HBox(12); + private final AtomicLong loadGeneration = new AtomicLong(); + + private AssetWorkspaceDetailsViewState viewState = AssetDetailsViewStateFactory.empty(); + private boolean readyMounted; + + public AssetDetailsControl( + ProjectReference projectReference, + StudioWorkspaceEventBus workspaceBus) { + this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); + this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); + this.summaryControl = new AssetDetailsSummaryControl(projectReference, workspaceBus); + this.contractControl = new AssetDetailsContractControl(workspaceBus); + this.actionsSection = createActionsSection(); + + getStyleClass().add("assets-workspace-pane"); + setSpacing(10); + + detailsTitleLabel.getStyleClass().addAll("assets-workspace-pane-title", "assets-workspace-details-title"); + + workspaceSummaryLabel.getStyleClass().add("assets-workspace-summary"); + detailsContent.getStyleClass().add("assets-workspace-details-content"); + primarySectionsRow.getStyleClass().add("assets-details-summary-actions-row"); + primarySectionsRow.setFillHeight(true); + HBox.setHgrow(summaryControl, Priority.ALWAYS); + HBox.setHgrow(actionsSection, Priority.ALWAYS); + summaryControl.setMaxWidth(Double.MAX_VALUE); + actionsSection.setMaxWidth(Double.MAX_VALUE); + contractControl.setMaxWidth(Double.MAX_VALUE); + summaryControl.prefWidthProperty().bind(primarySectionsRow.widthProperty().multiply(0.70d)); + actionsSection.prefWidthProperty().bind(primarySectionsRow.widthProperty().multiply(0.30d)); + primarySectionsRow.getChildren().setAll(summaryControl, actionsSection); + summaryControl.heightProperty().addListener((ignored, oldValue, newValue) -> syncActionsSectionHeight(newValue.doubleValue())); + detailsScroll.setContent(detailsContent); + detailsScroll.setFitToWidth(true); + detailsScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + detailsScroll.getStyleClass().add("assets-workspace-details-scroll"); + + getChildren().addAll(detailsTitleLabel, workspaceSummaryLabel, detailsScroll); + VBox.setVgrow(detailsScroll, Priority.ALWAYS); + render(); + } + + @Override + public StudioEventBindings eventBindings() { + return eventBindings; + } + + @Override + public void registerEventSubscriptions() { + eventBindings.listen(workspaceBus, StudioAssetsRefreshRequestedEvent.class).handle(event -> { + if (event.preferredAssetReference() == null) { + clearSelection(); + } + }); + eventBindings.listen(workspaceBus, StudioAssetsWorkspaceSelectionRequestedEvent.class).handle(event -> { + if (!isCurrentSelection(event.assetReference())) { + loadSelection(event.assetReference()); + } + }); + } + + public void scrollToTop() { + detailsScroll.setVvalue(0.0d); + } + + private void loadSelection(AssetReference assetReference) { + final long generation = loadGeneration.incrementAndGet(); + publishViewState(AssetDetailsViewStateFactory.loading(assetReference)); + Container.backgroundTasks().submit(() -> loadDetails(generation, assetReference)); + } + + private void clearSelection() { + loadGeneration.incrementAndGet(); + publishViewState(AssetDetailsViewStateFactory.empty()); + } + + private boolean isCurrentSelection(AssetReference assetReference) { + if (!Objects.equals(viewState.selectedAssetReference(), assetReference)) { + return false; + } + return viewState.detailsStatus() == AssetWorkspaceDetailsStatus.READY + || viewState.detailsStatus() == AssetWorkspaceDetailsStatus.LOADING; + } + + private void loadDetails(long generation, AssetReference assetReference) { + try { + final var response = Container.packer() + .workspaceService() + .getAssetDetails(new GetAssetDetailsRequest( + projectReference.toPackerProjectContext(), + assetReference)); + final AssetWorkspaceAssetDetails details = mapDetails(response.details()); + Platform.runLater(() -> applyLoadedDetails(generation, assetReference, details)); + } catch (RuntimeException exception) { + Platform.runLater(() -> applyLoadFailure(generation, assetReference, exception)); + } + } + + private void applyLoadedDetails( + long generation, + AssetReference assetReference, + AssetWorkspaceAssetDetails details) { + if (generation != loadGeneration.get()) { + return; + } + publishViewState(AssetDetailsViewStateFactory.ready(assetReference, details)); + } + + private void applyLoadFailure( + long generation, + AssetReference assetReference, + RuntimeException exception) { + if (generation != loadGeneration.get()) { + return; + } + final String message = exception.getMessage() == null || exception.getMessage().isBlank() + ? Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY) + : exception.getMessage(); + publishViewState(AssetDetailsViewStateFactory.error(assetReference, message)); + } + + private void publishViewState(AssetWorkspaceDetailsViewState nextViewState) { + viewState = Objects.requireNonNull(nextViewState, "nextViewState"); + render(); + workspaceBus.publish(new StudioAssetsDetailsViewStateChangedEvent(nextViewState)); + } + + private void render() { + switch (viewState.detailsStatus()) { + case EMPTY -> { + readyMounted = false; + setDetailsTitle(null); + setWorkspaceSummary(Container.i18n().text(I18n.ASSETS_SUMMARY_EMPTY)); + detailsContent.getChildren().setAll(AssetDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_NO_SELECTION))); + } + case LOADING -> { + readyMounted = false; + setDetailsTitle(null); + setWorkspaceSummary(Container.i18n().text(I18n.ASSETS_SUMMARY_LOADING)); + detailsContent.getChildren().setAll(AssetDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING))); + } + case ERROR -> { + readyMounted = false; + setDetailsTitle(null); + setWorkspaceSummary(Container.i18n().text(I18n.ASSETS_SUMMARY_ERROR)); + detailsContent.getChildren().setAll(AssetDetailsUiSupport.createSectionMessage( + Objects.requireNonNullElse(viewState.detailsErrorMessage(), Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY)))); + } + case READY -> { + if (viewState.selectedAssetDetails() == null) { + readyMounted = false; + setDetailsTitle(null); + setWorkspaceSummary(null); + detailsContent.getChildren().setAll(AssetDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_NO_SELECTION))); + return; + } + + setDetailsTitle(viewState.selectedAssetDetails().summary().assetName()); + setWorkspaceSummary(null); + if (!readyMounted) { + readyMounted = true; + detailsContent.getChildren().setAll(primarySectionsRow, contractControl); + } + syncActionsSectionHeight(summaryControl.getHeight()); + } + } + } + + private VBox createActionsSection() { + actionsContent.getStyleClass().add("assets-details-actions-content"); + actionsScroll.setContent(actionsContent); + actionsScroll.setFitToWidth(true); + actionsScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + actionsScroll.getStyleClass().add("assets-details-actions-scroll"); + return AssetDetailsUiSupport.createSection( + Container.i18n().text(I18n.ASSETS_SECTION_ACTIONS), + actionsScroll); + } + + private void syncActionsSectionHeight(double summaryHeight) { + if (summaryHeight <= 0.0d) { + return; + } + actionsSection.setMinHeight(summaryHeight); + actionsSection.setPrefHeight(summaryHeight); + actionsSection.setMaxHeight(summaryHeight); + } + + private void setDetailsTitle(String text) { + detailsTitleLabel.setText(text == null ? "" : text); + final boolean visible = text != null && !text.isBlank(); + detailsTitleLabel.setVisible(visible); + detailsTitleLabel.setManaged(visible); + } + + private void setWorkspaceSummary(String text) { + workspaceSummaryLabel.setText(text == null ? "" : text); + final boolean visible = text != null && !text.isBlank(); + workspaceSummaryLabel.setVisible(visible); + workspaceSummaryLabel.setManaged(visible); + } + + private AssetWorkspaceAssetDetails mapDetails(PackerAssetDetails details) { + return new AssetWorkspaceAssetDetails( + AssetListPackerMappings.mapSummary(details.summary()), + details.outputFormat(), + details.outputCodec(), + details.availableOutputCodecs(), + details.codecConfigurationFieldsByCodec(), + Map.copyOf(details.inputsByRole())); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java new file mode 100644 index 00000000..1e3a349c --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java @@ -0,0 +1,115 @@ +package p.studio.workspaces.assets.details; + +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import p.packer.assets.AssetFamilyCatalog; +import p.studio.Container; +import p.studio.projects.ProjectReference; +import p.studio.utilities.i18n.I18n; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetState; +import p.studio.workspaces.assets.messages.AssetWorkspaceBuildParticipation; + +import java.nio.file.Path; + +public final class AssetDetailsUiSupport { + private AssetDetailsUiSupport() { + } + + public static VBox createSection(String title, Node content) { + final VBox section = new VBox(10); + section.getStyleClass().add("assets-details-section"); + final Label titleLabel = new Label(title); + titleLabel.getStyleClass().add("assets-details-section-title"); + section.getChildren().addAll(titleLabel, content); + return section; + } + + public static Node createSectionMessage(String text) { + final Label label = new Label(text); + label.setWrapText(true); + label.getStyleClass().add("assets-details-section-message"); + return label; + } + + public static Node createKeyValueRow(String key, String value) { + final Label valueLabel = new Label(value); + valueLabel.getStyleClass().add("assets-details-value"); + valueLabel.setWrapText(true); + return createKeyValueRow(key, valueLabel); + } + + public static Node createKeyValueRow(String key, Node valueNode) { + final HBox row = new HBox(12); + row.setAlignment(Pos.TOP_LEFT); + final Label keyLabel = new Label(key); + keyLabel.getStyleClass().add("assets-details-key"); + HBox.setHgrow(valueNode, Priority.ALWAYS); + row.getChildren().addAll(keyLabel, valueNode); + return row; + } + + public static Label createChip(String toneClass, String text) { + final Label chip = new Label(text); + chip.getStyleClass().addAll("assets-details-chip", toneClass); + return chip; + } + + public static String registrationLabel(AssetWorkspaceAssetState state) { + return switch (state) { + case REGISTERED -> Container.i18n().text(I18n.ASSETS_VALUE_REGISTERED); + case UNREGISTERED -> Container.i18n().text(I18n.ASSETS_VALUE_UNREGISTERED); + }; + } + + public static String buildParticipationLabel(AssetWorkspaceBuildParticipation buildParticipation) { + return switch (buildParticipation) { + case INCLUDED -> Container.i18n().text(I18n.ASSETS_VALUE_INCLUDED); + case EXCLUDED -> Container.i18n().text(I18n.ASSETS_VALUE_EXCLUDED); + }; + } + + public static String booleanLabel(boolean value) { + return value + ? Container.i18n().text(I18n.ASSETS_VALUE_YES) + : Container.i18n().text(I18n.ASSETS_VALUE_NO); + } + + public static String typeLabel(AssetFamilyCatalog assetFamily) { + return assetFamily.displayName(); + } + + public static String typeChipTone(AssetFamilyCatalog assetFamily) { + return switch (assetFamily) { + case IMAGE_BANK -> "assets-details-chip-image"; + case PALETTE_BANK -> "assets-details-chip-palette"; + case SOUND_BANK -> "assets-details-chip-audio"; + case UNKNOWN -> "assets-details-chip-generic"; + }; + } + + public static String registrationChipTone(AssetWorkspaceAssetState state) { + return switch (state) { + case REGISTERED -> "assets-details-chip-registered"; + case UNREGISTERED -> "assets-details-chip-unregistered"; + }; + } + + public static String buildChipTone(AssetWorkspaceBuildParticipation buildParticipation) { + return switch (buildParticipation) { + case INCLUDED -> "assets-details-chip-included"; + case EXCLUDED -> "assets-details-chip-excluded"; + }; + } + + public static String projectRelativePath(ProjectReference projectReference, Path path) { + try { + return projectReference.rootPath().relativize(path.toAbsolutePath().normalize()).toString(); + } catch (RuntimeException runtimeException) { + return path.toString(); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsViewStateFactory.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsViewStateFactory.java new file mode 100644 index 00000000..6cd31b66 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsViewStateFactory.java @@ -0,0 +1,49 @@ +package p.studio.workspaces.assets.details; + +import p.packer.assets.AssetReference; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails; +import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsStatus; +import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState; + +import java.util.Objects; + +public final class AssetDetailsViewStateFactory { + private AssetDetailsViewStateFactory() { + } + + public static AssetWorkspaceDetailsViewState empty() { + return new AssetWorkspaceDetailsViewState( + AssetWorkspaceDetailsStatus.EMPTY, + null, + null, + null); + } + + public static AssetWorkspaceDetailsViewState loading(AssetReference assetReference) { + return new AssetWorkspaceDetailsViewState( + AssetWorkspaceDetailsStatus.LOADING, + Objects.requireNonNull(assetReference, "assetReference"), + null, + null); + } + + public static AssetWorkspaceDetailsViewState ready( + AssetReference assetReference, + AssetWorkspaceAssetDetails details) { + return new AssetWorkspaceDetailsViewState( + AssetWorkspaceDetailsStatus.READY, + Objects.requireNonNull(assetReference, "assetReference"), + Objects.requireNonNull(details, "details"), + null); + } + + public static AssetWorkspaceDetailsViewState error( + AssetReference assetReference, + String message) { + return new AssetWorkspaceDetailsViewState( + AssetWorkspaceDetailsStatus.ERROR, + assetReference, + null, + Objects.requireNonNullElse(message, "Unknown asset details error")); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetListPackerMappings.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetListPackerMappings.java new file mode 100644 index 00000000..4128e370 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetListPackerMappings.java @@ -0,0 +1,32 @@ +package p.studio.workspaces.assets.details; + +import p.packer.assets.PackerAssetSummary; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetState; +import p.studio.workspaces.assets.messages.AssetWorkspaceBuildParticipation; + +public final class AssetListPackerMappings { + private AssetListPackerMappings() { + } + + public static AssetWorkspaceAssetSummary mapSummary(PackerAssetSummary summary) { + final AssetWorkspaceAssetState state = switch (summary.state()) { + case REGISTERED -> AssetWorkspaceAssetState.REGISTERED; + case UNREGISTERED -> AssetWorkspaceAssetState.UNREGISTERED; + }; + final AssetWorkspaceBuildParticipation buildParticipation = switch (summary.buildParticipation()) { + case INCLUDED -> AssetWorkspaceBuildParticipation.INCLUDED; + case EXCLUDED -> AssetWorkspaceBuildParticipation.EXCLUDED; + }; + return new AssetWorkspaceAssetSummary( + summary.assetReference(), + summary.identity().assetName(), + state, + buildParticipation, + summary.identity().assetId(), + summary.assetFamily(), + summary.identity().assetRoot(), + summary.preloadEnabled(), + summary.hasDiagnostics()); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/contract/AssetContractDraft.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/contract/AssetContractDraft.java new file mode 100644 index 00000000..97f744c2 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/contract/AssetContractDraft.java @@ -0,0 +1,58 @@ +package p.studio.workspaces.assets.details.contract; + +import p.packer.assets.AssetReference; +import p.packer.assets.OutputCodecCatalog; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public record AssetContractDraft( + AssetReference assetReference, + boolean preload, + String bank, + OutputCodecCatalog selectedCodec, + Map codecFieldValues) { + + public AssetContractDraft { + Objects.requireNonNull(assetReference, "assetReference"); + bank = Objects.requireNonNullElse(bank, "unknown"); + selectedCodec = Objects.requireNonNullElse(selectedCodec, OutputCodecCatalog.UNKNOWN); + codecFieldValues = Map.copyOf(Objects.requireNonNull(codecFieldValues, "codecFieldValues")); + } + + public static AssetContractDraft fromDetails(AssetWorkspaceAssetDetails details) { + final Map codecFieldValues = new LinkedHashMap<>(); + details.codecConfigurationFieldsByCodec().forEach((codec, fields) -> + fields.forEach(field -> codecFieldValues.put(keyOf(codec, field.key()), field.value()))); + return new AssetContractDraft( + details.summary().assetReference(), + details.summary().preload(), + details.outputFormat(), + details.outputCodec(), + codecFieldValues); + } + + public AssetContractDraft withPreload(boolean preload) { + return new AssetContractDraft(assetReference, preload, bank, selectedCodec, codecFieldValues); + } + + public AssetContractDraft withSelectedCodec(OutputCodecCatalog selectedCodec) { + return new AssetContractDraft(assetReference, preload, bank, selectedCodec, codecFieldValues); + } + + public AssetContractDraft withCodecFieldValue(OutputCodecCatalog codec, String fieldKey, String value) { + final Map nextValues = new LinkedHashMap<>(codecFieldValues); + nextValues.put(keyOf(codec, fieldKey), Objects.requireNonNullElse(value, "")); + return new AssetContractDraft(assetReference, preload, bank, selectedCodec, nextValues); + } + + public String codecFieldValue(OutputCodecCatalog codec, String fieldKey, String fallback) { + return codecFieldValues.getOrDefault(keyOf(codec, fieldKey), Objects.requireNonNullElse(fallback, "")); + } + + private static String keyOf(OutputCodecCatalog codec, String fieldKey) { + return codec.name() + ":" + fieldKey; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/contract/AssetDetailsContractControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/contract/AssetDetailsContractControl.java new file mode 100644 index 00000000..eaee0fc4 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/contract/AssetDetailsContractControl.java @@ -0,0 +1,355 @@ +package p.studio.workspaces.assets.details.contract; + +import javafx.collections.FXCollections; +import javafx.scene.Node; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import p.packer.assets.OutputCodecCatalog; +import p.packer.assets.PackerCodecConfigurationField; +import p.studio.Container; +import p.studio.controls.forms.StudioFormActionBar; +import p.studio.controls.forms.StudioFormMode; +import p.studio.controls.forms.StudioFormSession; +import p.studio.controls.lifecycle.StudioControlLifecycle; +import p.studio.controls.lifecycle.StudioControlLifecycleSupport; +import p.studio.events.StudioWorkspaceEventBus; +import p.studio.utilities.i18n.I18n; +import p.studio.workspaces.assets.details.AssetDetailsUiSupport; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails; +import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState; +import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent; +import p.studio.workspaces.framework.StudioSubscriptionBag; + +import java.util.Objects; + +public final class AssetDetailsContractControl extends VBox implements StudioControlLifecycle { + private final StudioWorkspaceEventBus workspaceBus; + private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag(); + private final StudioFormActionBar actionBar = new StudioFormActionBar( + this::beginEdit, + this::applyDraft, + this::resetDraft, + this::cancelEdit); + + private AssetWorkspaceDetailsViewState viewState; + private StudioFormSession formSession; + + public AssetDetailsContractControl(StudioWorkspaceEventBus workspaceBus) { + StudioControlLifecycleSupport.install(this, this); + this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); + } + + @Override + public void subscribe() { + subscriptions.add(workspaceBus.subscribe(StudioAssetsDetailsViewStateChangedEvent.class, event -> { + viewState = event.viewState(); + syncFormSession(); + render(); + })); + } + + @Override + public void unsubscribe() { + subscriptions.clear(); + } + + private void syncFormSession() { + if (viewState == null || viewState.selectedAssetDetails() == null) { + formSession = null; + return; + } + + final AssetContractDraft source = AssetContractDraft.fromDetails(viewState.selectedAssetDetails()); + if (formSession == null) { + formSession = new StudioFormSession<>(source); + return; + } + + if (!Objects.equals(formSession.source(), source)) { + formSession.replaceSource(source); + } + } + + private void render() { + if (viewState == null || viewState.selectedAssetDetails() == null) { + getChildren().clear(); + return; + } + + if (formSession == null) { + syncFormSession(); + } + if (formSession == null) { + getChildren().clear(); + return; + } + + final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails(); + final AssetContractDraft draft = formSession.draft(); + final boolean editing = formSession.mode() == StudioFormMode.EDITING; + + final VBox generalColumn = new VBox(8); + generalColumn.getStyleClass().add("assets-details-contract-column"); + generalColumn.getChildren().setAll( + AssetDetailsUiSupport.createKeyValueRow( + Container.i18n().text(I18n.ASSETS_LABEL_PRELOAD), + createPreloadEditor(draft, editing)), + AssetDetailsUiSupport.createKeyValueRow( + Container.i18n().text(I18n.ASSETS_LABEL_BANK), + draft.bank())); + + final VBox codecColumn = new VBox(10); + codecColumn.getStyleClass().addAll("assets-details-contract-column", "assets-details-contract-codec-column"); + codecColumn.getChildren().setAll( + AssetDetailsUiSupport.createKeyValueRow( + Container.i18n().text(I18n.ASSETS_LABEL_CODEC), + createCodecEditor(details, draft, editing)), + createCodecConfigurationSection(details, draft, editing)); + + final HBox contractBody = new HBox(16); + contractBody.getStyleClass().add("assets-details-contract-body"); + HBox.setHgrow(generalColumn, Priority.ALWAYS); + HBox.setHgrow(codecColumn, Priority.ALWAYS); + generalColumn.prefWidthProperty().bind(contractBody.widthProperty().subtract(16).multiply(0.5d)); + codecColumn.prefWidthProperty().bind(contractBody.widthProperty().subtract(16).multiply(0.5d)); + generalColumn.setMaxWidth(Double.MAX_VALUE); + codecColumn.setMaxWidth(Double.MAX_VALUE); + contractBody.getChildren().setAll(generalColumn, codecColumn); + + final VBox content = new VBox(12); + content.getChildren().setAll(contractBody); + + actionBar.updateState(formSession.mode(), formSession.isDirty()); + content.getChildren().add(actionBar); + + final VBox section = AssetDetailsUiSupport.createSection( + Container.i18n().text(I18n.ASSETS_SECTION_RUNTIME_CONTRACT), + content); + section.getStyleClass().add("assets-details-contract-section"); + getChildren().setAll(section); + } + + private Node createPreloadEditor(AssetContractDraft draft, boolean editing) { + final CheckBox checkBox = new CheckBox(); + checkBox.getStyleClass().add("assets-details-readonly-check"); + checkBox.setSelected(draft.preload()); + if (editing) { + checkBox.selectedProperty().addListener((ignored, oldValue, newValue) -> { + if (!Objects.equals(oldValue, newValue)) { + formSession.updateDraft(current -> current.withPreload(newValue)); + actionBar.updateState(formSession.mode(), formSession.isDirty()); + } + }); + } else { + configureReadOnlyControl(checkBox); + } + return checkBox; + } + + private Node createCodecEditor( + AssetWorkspaceAssetDetails details, + AssetContractDraft draft, + boolean editing) { + final ComboBox codecCombo = new ComboBox<>(FXCollections.observableArrayList(details.availableOutputCodecs())); + codecCombo.getStyleClass().add("assets-details-combo"); + codecCombo.setMaxWidth(Double.MAX_VALUE); + codecCombo.setCellFactory(ignored -> new javafx.scene.control.ListCell<>() { + @Override + protected void updateItem(OutputCodecCatalog item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : item.displayName()); + } + }); + codecCombo.setButtonCell(new javafx.scene.control.ListCell<>() { + @Override + protected void updateItem(OutputCodecCatalog item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : item.displayName()); + } + }); + codecCombo.getSelectionModel().select(draft.selectedCodec()); + if (editing) { + codecCombo.valueProperty().addListener((ignored, oldValue, newValue) -> { + if (newValue != null && !Objects.equals(oldValue, newValue)) { + formSession.updateDraft(current -> current.withSelectedCodec(newValue)); + render(); + } + }); + } else { + configureReadOnlyControl(codecCombo); + } + return codecCombo; + } + + private Node createCodecConfigurationSection( + AssetWorkspaceAssetDetails details, + AssetContractDraft draft, + boolean editing) { + final VBox subsection = new VBox(8); + subsection.getStyleClass().add("assets-details-contract-metadata"); + final Label title = new Label(Container.i18n().text(I18n.ASSETS_SUBSECTION_CODEC_CONFIGURATION)); + title.getStyleClass().add("assets-details-subsection-title"); + final VBox content = new VBox(8); + content.getStyleClass().add("assets-details-contract-metadata-content"); + + if (draft.selectedCodec() == OutputCodecCatalog.NONE || draft.selectedCodec() == OutputCodecCatalog.UNKNOWN) { + content.getChildren().add(AssetDetailsUiSupport.createSectionMessage( + Container.i18n().text(I18n.ASSETS_DETAILS_CODEC_CONFIGURATION_EMPTY))); + return createCodecMetadataPane(title, content); + } + + final var fields = details.codecConfigurationFieldsByCodec().getOrDefault(draft.selectedCodec(), java.util.List.of()); + if (fields.isEmpty()) { + content.getChildren().add(AssetDetailsUiSupport.createSectionMessage( + Container.i18n().text(I18n.ASSETS_DETAILS_CODEC_CONFIGURATION_EMPTY))); + return createCodecMetadataPane(title, content); + } + + for (PackerCodecConfigurationField field : fields) { + content.getChildren().add(createCodecFieldRow(field, draft, editing)); + } + return createCodecMetadataPane(title, content); + } + + private Node createCodecMetadataPane(Label title, VBox content) { + final ScrollPane scrollPane = new ScrollPane(content); + scrollPane.setFitToWidth(true); + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scrollPane.getStyleClass().add("assets-details-contract-metadata-scroll"); + + final VBox container = new VBox(8); + container.getChildren().setAll(title, scrollPane); + VBox.setVgrow(scrollPane, Priority.ALWAYS); + return container; + } + + private Node createCodecFieldRow( + PackerCodecConfigurationField field, + AssetContractDraft draft, + boolean editing) { + final Node valueNode = switch (field.fieldType()) { + case BOOLEAN -> createBooleanField(field, draft, editing); + case ENUM -> createEnumField(field, draft, editing); + case INTEGER, TEXT -> createTextField(field, draft, editing); + }; + return AssetDetailsUiSupport.createKeyValueRow(field.label(), valueNode); + } + + private Node createBooleanField( + PackerCodecConfigurationField field, + AssetContractDraft draft, + boolean editing) { + final CheckBox checkBox = new CheckBox(); + checkBox.getStyleClass().add("assets-details-readonly-check"); + checkBox.setSelected(Boolean.parseBoolean(currentValue(field, draft))); + if (editing) { + checkBox.selectedProperty().addListener((ignored, oldValue, newValue) -> { + if (!Objects.equals(oldValue, newValue)) { + formSession.updateDraft(current -> current.withCodecFieldValue( + current.selectedCodec(), + field.key(), + Boolean.toString(newValue))); + actionBar.updateState(formSession.mode(), formSession.isDirty()); + } + }); + } else { + configureReadOnlyControl(checkBox); + } + return checkBox; + } + + private Node createEnumField( + PackerCodecConfigurationField field, + AssetContractDraft draft, + boolean editing) { + final ComboBox comboBox = new ComboBox<>(FXCollections.observableArrayList(field.options())); + comboBox.getStyleClass().add("assets-details-combo"); + comboBox.setMaxWidth(Double.MAX_VALUE); + comboBox.getSelectionModel().select(currentValue(field, draft)); + if (editing) { + comboBox.valueProperty().addListener((ignored, oldValue, newValue) -> { + if (newValue != null && !Objects.equals(oldValue, newValue)) { + formSession.updateDraft(current -> current.withCodecFieldValue( + current.selectedCodec(), + field.key(), + newValue)); + actionBar.updateState(formSession.mode(), formSession.isDirty()); + } + }); + } else { + configureReadOnlyControl(comboBox); + } + return comboBox; + } + + private Node createTextField( + PackerCodecConfigurationField field, + AssetContractDraft draft, + boolean editing) { + final TextField textField = new TextField(currentValue(field, draft)); + textField.getStyleClass().add("assets-workspace-search"); + textField.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(textField, Priority.ALWAYS); + if (editing) { + textField.textProperty().addListener((ignored, oldValue, newValue) -> { + if (!Objects.equals(oldValue, newValue)) { + formSession.updateDraft(current -> current.withCodecFieldValue( + current.selectedCodec(), + field.key(), + Objects.requireNonNullElse(newValue, ""))); + actionBar.updateState(formSession.mode(), formSession.isDirty()); + } + }); + } else { + textField.setEditable(false); + configureReadOnlyControl(textField); + } + return textField; + } + + private String currentValue(PackerCodecConfigurationField field, AssetContractDraft draft) { + return draft.codecFieldValue(draft.selectedCodec(), field.key(), field.value()); + } + + private void configureReadOnlyControl(javafx.scene.control.Control control) { + control.setMouseTransparent(true); + control.setFocusTraversable(false); + } + + private void beginEdit() { + if (formSession == null) { + return; + } + formSession.beginEdit(); + render(); + } + + private void applyDraft() { + if (formSession == null) { + return; + } + render(); + } + + private void resetDraft() { + if (formSession == null) { + return; + } + formSession.resetDraft(); + render(); + } + + private void cancelEdit() { + if (formSession == null) { + return; + } + formSession.cancelEdit(); + render(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/summary/AssetDetailsSummaryControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/summary/AssetDetailsSummaryControl.java new file mode 100644 index 00000000..89a4bdbd --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/summary/AssetDetailsSummaryControl.java @@ -0,0 +1,76 @@ +package p.studio.workspaces.assets.details.summary; + +import javafx.scene.layout.VBox; +import p.studio.Container; +import p.studio.controls.lifecycle.StudioControlLifecycle; +import p.studio.controls.lifecycle.StudioControlLifecycleSupport; +import p.studio.events.StudioWorkspaceEventBus; +import p.studio.projects.ProjectReference; +import p.studio.utilities.i18n.I18n; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary; +import p.studio.workspaces.assets.details.AssetDetailsUiSupport; +import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent; +import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState; +import p.studio.workspaces.framework.StudioSubscriptionBag; + +import java.util.Objects; + +public final class AssetDetailsSummaryControl extends VBox implements StudioControlLifecycle { + private final ProjectReference projectReference; + private final StudioWorkspaceEventBus workspaceBus; + private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag(); + + private AssetWorkspaceDetailsViewState viewState; + + public AssetDetailsSummaryControl(ProjectReference projectReference, StudioWorkspaceEventBus workspaceBus) { + StudioControlLifecycleSupport.install(this, this); + this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); + this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); + } + + @Override + public void subscribe() { + subscriptions.add(workspaceBus.subscribe(StudioAssetsDetailsViewStateChangedEvent.class, event -> { + viewState = event.viewState(); + render(); + })); + } + + @Override + public void unsubscribe() { + subscriptions.clear(); + } + + private void render() { + if (viewState == null || viewState.selectedAssetDetails() == null) { + getChildren().clear(); + return; + } + final AssetWorkspaceAssetSummary summary = viewState.selectedAssetDetails().summary(); + if (summary == null) { + getChildren().clear(); + return; + } + + final VBox content = new VBox(8); + content.getChildren().setAll( + AssetDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_ASSET_ID), summary.assetId() == null ? "—" : String.valueOf(summary.assetId())), + AssetDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), AssetDetailsUiSupport.projectRelativePath(projectReference, summary.assetRoot()))); + content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow( + Container.i18n().text(I18n.ASSETS_LABEL_TYPE), + AssetDetailsUiSupport.createChip( + AssetDetailsUiSupport.typeChipTone(summary.assetFamily()), + AssetDetailsUiSupport.typeLabel(summary.assetFamily())))); + content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow( + Container.i18n().text(I18n.ASSETS_LABEL_REGISTRATION), + AssetDetailsUiSupport.createChip( + AssetDetailsUiSupport.registrationChipTone(summary.state()), + AssetDetailsUiSupport.registrationLabel(summary.state())))); + content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow( + Container.i18n().text(I18n.ASSETS_LABEL_BUILD_PARTICIPATION), + AssetDetailsUiSupport.createChip( + AssetDetailsUiSupport.buildChipTone(summary.buildParticipation()), + AssetDetailsUiSupport.buildParticipationLabel(summary.buildParticipation())))); + getChildren().setAll(AssetDetailsUiSupport.createSection(Container.i18n().text(I18n.ASSETS_SECTION_SUMMARY), content)); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListControl.java new file mode 100644 index 00000000..17db1564 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListControl.java @@ -0,0 +1,312 @@ +package p.studio.workspaces.assets.list; + +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.control.ToggleButton; +import javafx.scene.layout.FlowPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import p.packer.assets.AssetReference; +import p.packer.assets.PackerAssetSummary; +import p.packer.messages.ListAssetsRequest; +import p.studio.Container; +import p.studio.events.StudioWorkspaceEventBus; +import p.studio.projects.ProjectReference; +import p.studio.utilities.i18n.I18n; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary; +import p.studio.workspaces.assets.AssetWorkspaceState; +import p.studio.workspaces.assets.details.AssetListPackerMappings; +import p.studio.workspaces.assets.messages.AssetListGroup; +import p.studio.workspaces.assets.messages.AssetListProjection; +import p.studio.workspaces.assets.messages.AssetListViewState; +import p.studio.workspaces.assets.messages.AssetWorkspaceStatus; +import p.studio.workspaces.assets.messages.events.StudioAssetsNavigatorViewStateChangedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshFailedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshStartedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshedEvent; +import p.studio.workspaces.framework.StudioEventAware; +import p.studio.workspaces.framework.StudioEventBindings; + +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +public final class AssetListControl extends VBox implements StudioEventAware { + private final ProjectReference projectReference; + private final StudioWorkspaceEventBus workspaceBus; + private final StudioEventBindings eventBindings = new StudioEventBindings(); + private final VBox navigatorContent = new VBox(8); + private final TextField searchField = new TextField(); + private final ToggleButton unregisteredFilterButton = new ToggleButton(); + private final ToggleButton diagnosticsFilterButton = new ToggleButton(); + private final ToggleButton preloadFilterButton = new ToggleButton(); + private final AtomicLong refreshGeneration = new AtomicLong(); + + private AssetListViewState viewState = new AssetListViewState( + AssetWorkspaceState.loading(null), + null, + Container.i18n().text(I18n.ASSETS_STATE_LOADING)); + + public AssetListControl( + ProjectReference projectReference, + StudioWorkspaceEventBus workspaceBus) { + this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); + this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); + + getStyleClass().add("assets-workspace-pane"); + setSpacing(8); + navigatorContent.getStyleClass().add("assets-workspace-navigator-content"); + getChildren().addAll( + createNavigatorTitle(), + createSearchField(), + createFilterBar(), + createNavigatorScroll()); + installProjectionInputs(); + render(); + } + + @Override + public StudioEventBindings eventBindings() { + return eventBindings; + } + + @Override + public void registerEventSubscriptions() { + eventBindings.listen(workspaceBus, StudioAssetsRefreshRequestedEvent.class).handle(event -> { + requestRefresh(event.preferredAssetReference()); + }); + eventBindings.listen(workspaceBus, StudioAssetsNavigatorViewStateChangedEvent.class).handle(event -> { + viewState = event.viewState(); + render(); + }); + } + + private Label createNavigatorTitle() { + final var navigatorTitle = new Label(); + navigatorTitle.textProperty().bind(Container.i18n().bind(I18n.ASSETS_NAVIGATOR_TITLE)); + navigatorTitle.getStyleClass().add("assets-workspace-pane-title"); + return navigatorTitle; + } + + private TextField createSearchField() { + searchField.setPromptText(Container.i18n().text(I18n.ASSETS_SEARCH_PROMPT)); + searchField.getStyleClass().add("assets-workspace-search"); + return searchField; + } + + private FlowPane createFilterBar() { + final var filterBar = new FlowPane(); + filterBar.setHgap(6); + filterBar.setVgap(6); + filterBar.setPadding(new Insets(4, 0, 4, 0)); + filterBar.getStyleClass().add("assets-workspace-filter-bar"); + filterBar.getChildren().addAll( + configureFilterButton(unregisteredFilterButton, I18n.ASSETS_FILTER_UNREGISTERED), + configureFilterButton(diagnosticsFilterButton, I18n.ASSETS_FILTER_DIAGNOSTICS), + configureFilterButton(preloadFilterButton, I18n.ASSETS_FILTER_PRELOAD)); + return filterBar; + } + + private ToggleButton configureFilterButton(ToggleButton button, I18n i18n) { + button.textProperty().bind(Container.i18n().bind(i18n)); + button.getStyleClass().addAll("studio-button", "studio-button-secondary", "studio-button-pill", "studio-button-toggle"); + return button; + } + + private ScrollPane createNavigatorScroll() { + final var navigatorScroll = new ScrollPane(navigatorContent); + navigatorScroll.setFitToWidth(true); + navigatorScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + navigatorScroll.getStyleClass().add("assets-workspace-navigator-scroll"); + VBox.setVgrow(navigatorScroll, Priority.ALWAYS); + return navigatorScroll; + } + + private void installProjectionInputs() { + searchField.textProperty().addListener((ignored, oldValue, newValue) -> rebuildProjection()); + unregisteredFilterButton.setOnAction(ignored -> rebuildProjection()); + diagnosticsFilterButton.setOnAction(ignored -> rebuildProjection()); + preloadFilterButton.setOnAction(ignored -> rebuildProjection()); + } + + private void requestRefresh(AssetReference preferredAssetReference) { + if (!Platform.isFxApplicationThread()) { + Platform.runLater(() -> requestRefresh(preferredAssetReference)); + return; + } + + final AssetWorkspaceState previousState = viewState.workspaceState(); + final AssetReference assetReference = preferredAssetReference != null + ? preferredAssetReference + : previousState.selectedAssetReference(); + final long generation = refreshGeneration.incrementAndGet(); + + publishNavigatorViewState(new AssetListViewState( + AssetWorkspaceState.loading(previousState), + null, + Container.i18n().text(I18n.ASSETS_STATE_LOADING))); + workspaceBus.publish(new StudioAssetsWorkspaceRefreshStartedEvent()); + + Container.backgroundTasks().submit(() -> loadAssets(generation, assetReference)); + } + + private void loadAssets( + long generation, + AssetReference preferredAssetReference) { + try { + final var projectContext = projectReference.toPackerProjectContext(); + final var response = Container.packer() + .workspaceService() + .listAssets(new ListAssetsRequest(projectContext)); + final List assets = response.assets().stream() + .map(this::mapAsset) + .toList(); + final AssetWorkspaceState workspaceState = AssetWorkspaceState.ready(assets, preferredAssetReference); + Platform.runLater(() -> applyRefreshResult(generation, workspaceState)); + } catch (RuntimeException exception) { + Platform.runLater(() -> applyRefreshFailure(generation, exception)); + } + } + + private void applyRefreshResult(long generation, AssetWorkspaceState workspaceState) { + if (generation != refreshGeneration.get()) { + return; + } + final AssetListProjection projection = AssetListProjectionBuilder.build( + workspaceState.assets(), + projectReference.rootPath(), + searchField.getText(), + currentFilters()); + final AssetListViewState nextViewState = new AssetListViewState( + workspaceState, + projection, + buildViewMessage(workspaceState, projection)); + publishNavigatorViewState(nextViewState); + workspaceBus.publish(new StudioAssetsWorkspaceRefreshedEvent( + nextViewState.workspaceState().assets().size())); + } + + private void applyRefreshFailure(long generation, RuntimeException exception) { + if (generation != refreshGeneration.get()) { + return; + } + final String message = exception.getMessage() == null || exception.getMessage().isBlank() + ? Container.i18n().text(I18n.ASSETS_STATE_ERROR) + : exception.getMessage(); + publishNavigatorViewState(new AssetListViewState( + AssetWorkspaceState.error(viewState.workspaceState(), message), + viewState.projection(), + message)); + workspaceBus.publish(new StudioAssetsWorkspaceRefreshFailedEvent(message)); + } + + private void rebuildProjection() { + if (!Platform.isFxApplicationThread()) { + Platform.runLater(this::rebuildProjection); + return; + } + final AssetWorkspaceState workspaceState = viewState.workspaceState(); + if (workspaceState.status() == AssetWorkspaceStatus.LOADING || workspaceState.status() == AssetWorkspaceStatus.ERROR) { + return; + } + + final AssetListProjection projection = AssetListProjectionBuilder.build( + workspaceState.assets(), + projectReference.rootPath(), + searchField.getText(), + currentFilters()); + publishNavigatorViewState(new AssetListViewState( + workspaceState, + projection, + buildViewMessage(workspaceState, projection))); + } + + private void publishNavigatorViewState(AssetListViewState nextViewState) { + viewState = Objects.requireNonNull(nextViewState, "nextViewState"); + workspaceBus.publish(new StudioAssetsNavigatorViewStateChangedEvent(nextViewState)); + } + + private AssetWorkspaceAssetSummary mapAsset(PackerAssetSummary summary) { + return AssetListPackerMappings.mapSummary(summary); + } + + private Set currentFilters() { + final EnumSet filters = EnumSet.noneOf(AssetListFilter.class); + if (unregisteredFilterButton.isSelected()) { + filters.add(AssetListFilter.UNREGISTERED); + } + if (diagnosticsFilterButton.isSelected()) { + filters.add(AssetListFilter.DIAGNOSTICS); + } + if (preloadFilterButton.isSelected()) { + filters.add(AssetListFilter.PRELOAD); + } + return filters; + } + + private String buildViewMessage(AssetWorkspaceState workspaceState, AssetListProjection projection) { + return switch (workspaceState.status()) { + case LOADING -> Container.i18n().text(I18n.ASSETS_STATE_LOADING); + case EMPTY -> Container.i18n().text(I18n.ASSETS_STATE_EMPTY); + case ERROR -> workspaceState.errorMessage(); + case READY -> projection == null || projection.isEmpty() + ? Container.i18n().text(I18n.ASSETS_STATE_NO_RESULTS) + : Container.i18n().text(I18n.ASSETS_STATE_READY); + }; + } + + private void render() { + navigatorContent.getChildren().clear(); + final AssetWorkspaceStatus status = viewState.workspaceState().status(); + if (status == AssetWorkspaceStatus.LOADING) { + navigatorContent.getChildren().setAll(createMessageNode(viewState.message())); + return; + } + if (status == AssetWorkspaceStatus.ERROR) { + navigatorContent.getChildren().setAll(createMessageNode(viewState.message())); + return; + } + if (status == AssetWorkspaceStatus.EMPTY || !viewState.hasProjection()) { + navigatorContent.getChildren().setAll(createMessageNode(viewState.message())); + return; + } + + for (AssetListGroup group : viewState.projection().groups()) { + final var groupBox = createGroupBox(group.label()); + for (AssetWorkspaceAssetSummary asset : group.assets()) { + groupBox.getChildren().add(new AssetListItemControl( + projectReference, + workspaceBus, + asset)); + } + navigatorContent.getChildren().add(groupBox); + } + } + + private VBox createGroupBox(String label) { + final var groupBox = new VBox(6); + groupBox.getStyleClass().add("assets-workspace-group"); + groupBox.getChildren().add(createGroupLabel(label)); + return groupBox; + } + + private Label createGroupLabel(String text) { + final var groupLabel = new Label(text); + groupLabel.getStyleClass().add("assets-workspace-group-label"); + return groupLabel; + } + + private Node createMessageNode(String text) { + final var label = new Label(text); + label.getStyleClass().add("assets-workspace-empty-state"); + label.setWrapText(true); + return label; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListFilter.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListFilter.java new file mode 100644 index 00000000..81bd65a0 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListFilter.java @@ -0,0 +1,7 @@ +package p.studio.workspaces.assets.list; + +public enum AssetListFilter { + UNREGISTERED, + DIAGNOSTICS, + PRELOAD +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListItemControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListItemControl.java new file mode 100644 index 00000000..e8a71ad9 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListItemControl.java @@ -0,0 +1,130 @@ +package p.studio.workspaces.assets.list; + +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import p.packer.assets.AssetFamilyCatalog; +import p.studio.Container; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceSelectionRequestedEvent; +import p.studio.events.StudioWorkspaceEventBus; +import p.studio.projects.ProjectReference; +import p.studio.utilities.i18n.I18n; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetState; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary; +import p.studio.workspaces.assets.messages.AssetWorkspaceBuildParticipation; + +import java.nio.file.Path; +import java.util.Objects; + +public final class AssetListItemControl extends VBox { + private final ProjectReference projectReference; + private final Path projectRoot; + private final StudioWorkspaceEventBus workspaceBus; + + private AssetWorkspaceAssetSummary summary; + + public AssetListItemControl( + ProjectReference projectReference, + StudioWorkspaceEventBus workspaceBus, + AssetWorkspaceAssetSummary summary) { + this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); + this.projectRoot = this.projectReference.rootPath(); + this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus"); + this.summary = Objects.requireNonNull(summary, "summary"); + render(); + } + + private void render() { + getChildren().clear(); + getStyleClass().setAll("assets-workspace-asset-row", assetRowToneClass(summary.assetFamily())); + getChildren().setAll(createTopLine(), createPathLabel()); + setOnMouseClicked(event -> workspaceBus.publish( + new StudioAssetsWorkspaceSelectionRequestedEvent(summary.assetReference()))); + } + + private HBox createTopLine() { + final var topLine = new HBox(8); + topLine.setAlignment(Pos.CENTER_LEFT); + topLine.getChildren().addAll( + createNameLabel(), + createSpacer(), + createBadgesBox()); + return topLine; + } + + private Label createNameLabel() { + final var name = new Label(summary.assetName()); + name.getStyleClass().addAll("assets-workspace-asset-name", assetNameToneClass(summary.assetFamily())); + return name; + } + + private Region createSpacer() { + final var spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + return spacer; + } + + private HBox createBadgesBox() { + final var badges = new HBox(6); + badges.setAlignment(Pos.CENTER_RIGHT); + badges.getStyleClass().add("assets-workspace-asset-badges"); + populateBadges(badges); + return badges; + } + + private Label createPathLabel() { + final var path = new Label(AssetListProjectionBuilder.relativeRoot(summary, projectRoot)); + path.getStyleClass().add("assets-workspace-asset-path"); + return path; + } + + private void populateBadges(HBox badges) { + if (summary.state() == AssetWorkspaceAssetState.UNREGISTERED) { + badges.getChildren().add(createBadge(Container.i18n().text(I18n.ASSETS_BADGE_UNREGISTERED), "assets-workspace-badge-orphan")); + } else if (summary.buildParticipation() == AssetWorkspaceBuildParticipation.INCLUDED) { + badges.getChildren().add(createBadge(buildParticipationLabel(summary.buildParticipation()), "assets-workspace-badge-preload")); + if (summary.preload()) { + badges.getChildren().add(createBadge(Container.i18n().text(I18n.ASSETS_BADGE_PRELOAD), "assets-workspace-badge-preload")); + } + } else { + badges.getChildren().add(createBadge(buildParticipationLabel(summary.buildParticipation()), "assets-workspace-badge-diagnostics")); + } + if (summary.hasDiagnostics()) { + badges.getChildren().add(createBadge(Container.i18n().text(I18n.ASSETS_BADGE_DIAGNOSTICS), "assets-workspace-badge-diagnostics")); + } + } + + private Label createBadge(String text, String styleClass) { + final Label badge = new Label(text); + badge.getStyleClass().addAll("assets-workspace-badge", styleClass); + return badge; + } + + private String buildParticipationLabel(AssetWorkspaceBuildParticipation buildParticipation) { + return switch (buildParticipation) { + case INCLUDED -> Container.i18n().text(I18n.ASSETS_VALUE_INCLUDED); + case EXCLUDED -> Container.i18n().text(I18n.ASSETS_VALUE_EXCLUDED); + }; + } + + private String assetRowToneClass(AssetFamilyCatalog assetFamily) { + return switch (assetFamily) { + case IMAGE_BANK -> "assets-workspace-asset-row-tone-image"; + case PALETTE_BANK -> "assets-workspace-asset-row-tone-palette"; + case SOUND_BANK -> "assets-workspace-asset-row-tone-audio"; + default -> "assets-workspace-asset-row-tone-generic"; + }; + } + + private String assetNameToneClass(AssetFamilyCatalog assetFamily) { + return switch (assetFamily) { + case IMAGE_BANK -> "assets-workspace-asset-name-tone-image"; + case PALETTE_BANK -> "assets-workspace-asset-name-tone-palette"; + case SOUND_BANK -> "assets-workspace-asset-name-tone-audio"; + default -> "assets-workspace-asset-name-tone-generic"; + }; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilder.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListProjectionBuilder.java similarity index 62% rename from prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilder.java rename to prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListProjectionBuilder.java index 810b1b36..f9873d91 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilder.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListProjectionBuilder.java @@ -1,22 +1,27 @@ -package p.studio.workspaces.assets; +package p.studio.workspaces.assets.list; + +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetState; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary; +import p.studio.workspaces.assets.messages.AssetListGroup; +import p.studio.workspaces.assets.messages.AssetListProjection; import java.nio.file.Path; import java.util.*; -public final class AssetNavigatorProjectionBuilder { - private AssetNavigatorProjectionBuilder() { +public final class AssetListProjectionBuilder { + private AssetListProjectionBuilder() { } - public static AssetNavigatorProjection build( + public static AssetListProjection build( List assets, Path projectRoot, String searchQuery, - Set filters) { + Set filters) { Objects.requireNonNull(assets, "assets"); final Path normalizedProjectRoot = Objects.requireNonNull(projectRoot, "projectRoot").toAbsolutePath().normalize(); final String normalizedQuery = normalizeQuery(searchQuery); - final Set normalizedFilters = filters == null || filters.isEmpty() - ? EnumSet.noneOf(AssetNavigatorFilter.class) + final Set normalizedFilters = filters == null || filters.isEmpty() + ? EnumSet.noneOf(AssetListFilter.class) : EnumSet.copyOf(filters); final Map> grouped = new LinkedHashMap<>(); @@ -28,37 +33,35 @@ public final class AssetNavigatorProjectionBuilder { .add(asset); } - final List groups = grouped.entrySet().stream() - .map(entry -> new AssetNavigatorGroup(entry.getKey(), entry.getValue())) + final List groups = grouped.entrySet().stream() + .map(entry -> new AssetListGroup(entry.getKey(), entry.getValue())) .toList(); final int visibleAssetCount = groups.stream().mapToInt(group -> group.assets().size()).sum(); - return new AssetNavigatorProjection(groups, visibleAssetCount); + return new AssetListProjection(groups, visibleAssetCount); } - static String relativeRoot(AssetWorkspaceAssetSummary asset, Path projectRoot) { + public static String relativeRoot(AssetWorkspaceAssetSummary asset, Path projectRoot) { return relativize(asset.assetRoot(), projectRoot).toString().replace('\\', '/'); } - private static boolean matchesFilters(AssetWorkspaceAssetSummary asset, Set filters) { + private static boolean matchesFilters(AssetWorkspaceAssetSummary asset, Set filters) { if (filters.isEmpty()) { return true; } - final boolean includeRegistered = filters.contains(AssetNavigatorFilter.REGISTERED); - final boolean includeUnregistered = filters.contains(AssetNavigatorFilter.UNREGISTERED); - if (includeRegistered || includeUnregistered) { - final boolean stateMatches = (includeRegistered && asset.state() == AssetWorkspaceAssetState.REGISTERED) - || (includeUnregistered && asset.state() == AssetWorkspaceAssetState.UNREGISTERED); + final boolean includeUnregistered = filters.contains(AssetListFilter.UNREGISTERED); + if (includeUnregistered) { + final boolean stateMatches = asset.state() == AssetWorkspaceAssetState.UNREGISTERED; if (!stateMatches) { return false; } } - if (filters.contains(AssetNavigatorFilter.DIAGNOSTICS) && !asset.hasDiagnostics()) { + if (filters.contains(AssetListFilter.DIAGNOSTICS) && !asset.hasDiagnostics()) { return false; } - if (filters.contains(AssetNavigatorFilter.PRELOAD) && !asset.preload()) { + if (filters.contains(AssetListFilter.PRELOAD) && !asset.preload()) { return false; } @@ -72,17 +75,14 @@ public final class AssetNavigatorProjectionBuilder { final String relativeRoot = relativeRoot(asset, projectRoot); return asset.assetName().toLowerCase(Locale.ROOT).contains(normalizedQuery) - || asset.assetFamily().toLowerCase(Locale.ROOT).contains(normalizedQuery) + || asset.assetFamily().matchesQuery(normalizedQuery) || relativeRoot.toLowerCase(Locale.ROOT).contains(normalizedQuery); } private static String groupLabel(AssetWorkspaceAssetSummary asset, Path projectRoot) { final Path relativeRoot = relativize(asset.assetRoot(), projectRoot); final Path parent = relativeRoot.getParent(); - if (parent == null) { - return relativeRoot.toString().replace('\\', '/'); - } - return parent.toString().replace('\\', '/'); + return Objects.requireNonNullElse(parent, relativeRoot).toString().replace('\\', '/'); } private static Path relativize(Path assetRoot, Path projectRoot) { diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorGroup.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetListGroup.java similarity index 66% rename from prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorGroup.java rename to prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetListGroup.java index 4ee24553..85996386 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorGroup.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetListGroup.java @@ -1,10 +1,10 @@ -package p.studio.workspaces.assets; +package p.studio.workspaces.assets.messages; import java.util.List; import java.util.Objects; -public record AssetNavigatorGroup(String label, List assets) { - public AssetNavigatorGroup { +public record AssetListGroup(String label, List assets) { + public AssetListGroup { label = Objects.requireNonNull(label, "label").trim(); assets = List.copyOf(Objects.requireNonNull(assets, "assets")); if (label.isBlank()) { diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjection.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetListProjection.java similarity index 66% rename from prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjection.java rename to prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetListProjection.java index ccdcc279..e8382e61 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetNavigatorProjection.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetListProjection.java @@ -1,10 +1,10 @@ -package p.studio.workspaces.assets; +package p.studio.workspaces.assets.messages; import java.util.List; import java.util.Objects; -public record AssetNavigatorProjection(List groups, int visibleAssetCount) { - public AssetNavigatorProjection { +public record AssetListProjection(List groups, int visibleAssetCount) { + public AssetListProjection { groups = List.copyOf(Objects.requireNonNull(groups, "groups")); if (visibleAssetCount < 0) { throw new IllegalArgumentException("visibleAssetCount must not be negative"); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceNavigatorViewState.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetListViewState.java similarity index 61% rename from prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceNavigatorViewState.java rename to prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetListViewState.java index a8b48a5a..9370bc36 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceNavigatorViewState.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetListViewState.java @@ -1,13 +1,14 @@ -package p.studio.workspaces.assets; +package p.studio.workspaces.assets.messages; +import p.studio.workspaces.assets.AssetWorkspaceState; import java.util.Objects; -public record AssetWorkspaceNavigatorViewState( +public record AssetListViewState( AssetWorkspaceState workspaceState, - AssetNavigatorProjection projection, + AssetListProjection projection, String message) { - public AssetWorkspaceNavigatorViewState { + public AssetListViewState { Objects.requireNonNull(workspaceState, "workspaceState"); Objects.requireNonNull(message, "message"); } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetDetails.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetDetails.java new file mode 100644 index 00000000..0f8c657b --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetDetails.java @@ -0,0 +1,27 @@ +package p.studio.workspaces.assets.messages; + +import p.packer.assets.PackerCodecConfigurationField; +import p.packer.assets.OutputCodecCatalog; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public record AssetWorkspaceAssetDetails( + AssetWorkspaceAssetSummary summary, + String outputFormat, + OutputCodecCatalog outputCodec, + List availableOutputCodecs, + Map> codecConfigurationFieldsByCodec, + Map> inputsByRole) { + + public AssetWorkspaceAssetDetails { + Objects.requireNonNull(summary, "summary"); + outputFormat = Objects.requireNonNullElse(outputFormat, "unknown"); + outputCodec = Objects.requireNonNullElse(outputCodec, OutputCodecCatalog.UNKNOWN); + availableOutputCodecs = List.copyOf(Objects.requireNonNull(availableOutputCodecs, "availableOutputCodecs")); + codecConfigurationFieldsByCodec = Map.copyOf(Objects.requireNonNull(codecConfigurationFieldsByCodec, "codecConfigurationFieldsByCodec")); + inputsByRole = Map.copyOf(Objects.requireNonNull(inputsByRole, "inputsByRole")); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetState.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetState.java similarity index 62% rename from prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetState.java rename to prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetState.java index 118f901f..92a45749 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetState.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetState.java @@ -1,4 +1,4 @@ -package p.studio.workspaces.assets; +package p.studio.workspaces.assets.messages; public enum AssetWorkspaceAssetState { REGISTERED, diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetSummary.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetSummary.java similarity index 77% rename from prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetSummary.java rename to prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetSummary.java index d6dbd00a..c334bb72 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetSummary.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetSummary.java @@ -1,25 +1,28 @@ -package p.studio.workspaces.assets; +package p.studio.workspaces.assets.messages; + +import p.packer.assets.AssetFamilyCatalog; +import p.packer.assets.AssetReference; import java.nio.file.Path; import java.util.Objects; public record AssetWorkspaceAssetSummary( - AssetWorkspaceSelectionKey selectionKey, + AssetReference assetReference, String assetName, AssetWorkspaceAssetState state, AssetWorkspaceBuildParticipation buildParticipation, Integer assetId, - String assetFamily, + AssetFamilyCatalog assetFamily, Path assetRoot, boolean preload, boolean hasDiagnostics) { public AssetWorkspaceAssetSummary { - Objects.requireNonNull(selectionKey, "selectionKey"); + Objects.requireNonNull(assetReference, "assetReference"); assetName = Objects.requireNonNull(assetName, "assetName").trim(); Objects.requireNonNull(state, "state"); Objects.requireNonNull(buildParticipation, "buildParticipation"); - assetFamily = Objects.requireNonNullElse(assetFamily, "unknown").trim(); + assetFamily = Objects.requireNonNullElse(assetFamily, AssetFamilyCatalog.UNKNOWN); assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); if (assetName.isBlank()) { throw new IllegalArgumentException("assetName must not be blank"); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceBuildParticipation.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceBuildParticipation.java similarity index 63% rename from prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceBuildParticipation.java rename to prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceBuildParticipation.java index 279c1d76..cbd750e7 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceBuildParticipation.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceBuildParticipation.java @@ -1,4 +1,4 @@ -package p.studio.workspaces.assets; +package p.studio.workspaces.assets.messages; public enum AssetWorkspaceBuildParticipation { INCLUDED, diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDetailsStatus.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceDetailsStatus.java similarity index 66% rename from prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDetailsStatus.java rename to prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceDetailsStatus.java index 3fcfacad..fef5616e 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDetailsStatus.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceDetailsStatus.java @@ -1,4 +1,4 @@ -package p.studio.workspaces.assets; +package p.studio.workspaces.assets.messages; public enum AssetWorkspaceDetailsStatus { EMPTY, diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceDetailsViewState.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceDetailsViewState.java new file mode 100644 index 00000000..ee3ae715 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceDetailsViewState.java @@ -0,0 +1,16 @@ +package p.studio.workspaces.assets.messages; + +import p.packer.assets.AssetReference; + +import java.util.Objects; + +public record AssetWorkspaceDetailsViewState( + AssetWorkspaceDetailsStatus detailsStatus, + AssetReference selectedAssetReference, + AssetWorkspaceAssetDetails selectedAssetDetails, + String detailsErrorMessage) { + + public AssetWorkspaceDetailsViewState { + Objects.requireNonNull(detailsStatus, "detailsStatus"); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceStatus.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceStatus.java similarity index 64% rename from prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceStatus.java rename to prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceStatus.java index d7f3733b..30740388 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceStatus.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceStatus.java @@ -1,4 +1,4 @@ -package p.studio.workspaces.assets; +package p.studio.workspaces.assets.messages; public enum AssetWorkspaceStatus { LOADING, diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsDetailsViewStateChangedEvent.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsDetailsViewStateChangedEvent.java similarity index 55% rename from prometeu-studio/src/main/java/p/studio/events/StudioAssetsDetailsViewStateChangedEvent.java rename to prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsDetailsViewStateChangedEvent.java index 764e8fe2..9401d5a4 100644 --- a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsDetailsViewStateChangedEvent.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsDetailsViewStateChangedEvent.java @@ -1,15 +1,13 @@ -package p.studio.events; +package p.studio.workspaces.assets.messages.events; -import p.studio.projects.ProjectReference; -import p.studio.workspaces.assets.AssetWorkspaceDetailsViewState; +import p.studio.events.StudioEvent; +import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState; import java.util.Objects; public record StudioAssetsDetailsViewStateChangedEvent( - ProjectReference project, AssetWorkspaceDetailsViewState viewState) implements StudioEvent { public StudioAssetsDetailsViewStateChangedEvent { - Objects.requireNonNull(project, "project"); Objects.requireNonNull(viewState, "viewState"); } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsNavigatorViewStateChangedEvent.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsNavigatorViewStateChangedEvent.java new file mode 100644 index 00000000..58e78b60 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsNavigatorViewStateChangedEvent.java @@ -0,0 +1,13 @@ +package p.studio.workspaces.assets.messages.events; + +import p.studio.events.StudioEvent; +import p.studio.workspaces.assets.messages.AssetListViewState; + +import java.util.Objects; + +public record StudioAssetsNavigatorViewStateChangedEvent( + AssetListViewState viewState) implements StudioEvent { + public StudioAssetsNavigatorViewStateChangedEvent { + Objects.requireNonNull(viewState, "viewState"); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsRefreshRequestedEvent.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsRefreshRequestedEvent.java new file mode 100644 index 00000000..3f9f79ef --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsRefreshRequestedEvent.java @@ -0,0 +1,11 @@ +package p.studio.workspaces.assets.messages.events; + +import p.packer.assets.AssetReference; +import p.studio.events.StudioEvent; + +public record StudioAssetsRefreshRequestedEvent( + AssetReference preferredAssetReference) implements StudioEvent { + public StudioAssetsRefreshRequestedEvent() { + this(null); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsWorkspaceRefreshFailedEvent.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsWorkspaceRefreshFailedEvent.java new file mode 100644 index 00000000..3c9ba8cc --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsWorkspaceRefreshFailedEvent.java @@ -0,0 +1,11 @@ +package p.studio.workspaces.assets.messages.events; + +import p.studio.events.StudioEvent; + +import java.util.Objects; + +public record StudioAssetsWorkspaceRefreshFailedEvent(String message) implements StudioEvent { + public StudioAssetsWorkspaceRefreshFailedEvent { + Objects.requireNonNull(message, "message"); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsWorkspaceRefreshStartedEvent.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsWorkspaceRefreshStartedEvent.java new file mode 100644 index 00000000..bd2d5580 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsWorkspaceRefreshStartedEvent.java @@ -0,0 +1,6 @@ +package p.studio.workspaces.assets.messages.events; + +import p.studio.events.StudioEvent; + +public record StudioAssetsWorkspaceRefreshStartedEvent() implements StudioEvent { +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsWorkspaceRefreshedEvent.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsWorkspaceRefreshedEvent.java new file mode 100644 index 00000000..95598ba2 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsWorkspaceRefreshedEvent.java @@ -0,0 +1,11 @@ +package p.studio.workspaces.assets.messages.events; + +import p.studio.events.StudioEvent; + +public record StudioAssetsWorkspaceRefreshedEvent(int assetCount) implements StudioEvent { + public StudioAssetsWorkspaceRefreshedEvent { + if (assetCount < 0) { + throw new IllegalArgumentException("assetCount must not be negative"); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsWorkspaceSelectionRequestedEvent.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsWorkspaceSelectionRequestedEvent.java new file mode 100644 index 00000000..b77b24a7 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioAssetsWorkspaceSelectionRequestedEvent.java @@ -0,0 +1,13 @@ +package p.studio.workspaces.assets.messages.events; + +import p.packer.assets.AssetReference; +import p.studio.events.StudioEvent; + +import java.util.Objects; + +public record StudioAssetsWorkspaceSelectionRequestedEvent( + AssetReference assetReference) implements StudioEvent { + public StudioAssetsWorkspaceSelectionRequestedEvent { + Objects.requireNonNull(assetReference, "assetReference"); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/wizards/AddAssetWizard.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/wizards/AddAssetWizard.java new file mode 100644 index 00000000..7706f15d --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/wizards/AddAssetWizard.java @@ -0,0 +1,439 @@ +package p.studio.workspaces.assets.wizards; + +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +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.VBox; +import javafx.stage.DirectoryChooser; +import javafx.stage.Modality; +import javafx.stage.Stage; +import javafx.stage.Window; +import p.packer.PackerOperationStatus; +import p.packer.assets.AssetFamilyCatalog; +import p.packer.assets.AssetReference; +import p.packer.assets.OutputCodecCatalog; +import p.packer.assets.OutputFormatCatalog; +import p.packer.messages.CreateAssetRequest; +import p.packer.messages.CreateAssetResult; +import p.studio.Container; +import p.studio.projects.ProjectReference; +import p.studio.utilities.i18n.I18n; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +public final class AddAssetWizard { + private final ProjectReference projectReference; + private final Stage stage; + private final AtomicReference result = new AtomicReference<>(); + private final Label stepTitle = new Label(); + private final Label stepDescription = new Label(); + private final VBox stepBody = new VBox(12); + private final Label feedbackLabel = new Label(); + private final Button backButton = new Button(); + private final Button nextButton = new Button(); + private final Button createButton = new Button(); + private final TextField assetRootField = new TextField(); + private final TextField assetNameField = new TextField(); + private final ComboBox assetFamilyCombo = new ComboBox<>(); + private final ComboBox outputFormatCombo = new ComboBox<>(); + private final ComboBox outputCodecCombo = new ComboBox<>(); + private final CheckBox preloadCheckBox = new CheckBox(); + + private int stepIndex; + private boolean creating; + + private AddAssetWizard(Window owner, ProjectReference projectReference) { + this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); + 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(), 720, 500)); + stage.getScene().getStylesheets().add(Container.theme().getDefaultTheme()); + + preloadCheckBox.setSelected(false); + configureAssetFamilyCombo(); + configureOutputFormatCombo(); + configureOutputCodecCombo(); + renderStep(); + } + + public static Optional showAndWait(Window owner, ProjectReference projectReference) { + final AddAssetWizard wizard = new AddAssetWizard(owner, projectReference); + 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"); + 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 -> finishCreate()); + + final Button cancelButton = new Button(); + cancelButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_CANCEL)); + cancelButton.getStyleClass().addAll("studio-button", "studio-button-cancel"); + cancelButton.setOnAction(ignored -> stage.close()); + + final HBox actions = new HBox(12, backButton, nextButton, createButton, cancelButton); + 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 configureAssetFamilyCombo() { + assetFamilyCombo.setItems(FXCollections.observableArrayList( + java.util.Arrays.stream(AssetFamilyCatalog.values()) + .filter(candidate -> candidate != AssetFamilyCatalog.UNKNOWN) + .toList())); + assetFamilyCombo.setMaxWidth(Double.MAX_VALUE); + assetFamilyCombo.setPromptText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_PROMPT_TYPE)); + assetFamilyCombo.setCellFactory(ignored -> new javafx.scene.control.ListCell<>() { + @Override + protected void updateItem(AssetFamilyCatalog item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : item.displayName()); + } + }); + assetFamilyCombo.setButtonCell(new javafx.scene.control.ListCell<>() { + @Override + protected void updateItem(AssetFamilyCatalog item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : item.displayName()); + } + }); + assetFamilyCombo.valueProperty().addListener((ignored, oldValue, newValue) -> { + if (!Objects.equals(oldValue, newValue)) { + refreshOutputFormats(); + } + }); + } + + private void configureOutputFormatCombo() { + outputFormatCombo.setMaxWidth(Double.MAX_VALUE); + outputFormatCombo.setPromptText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_PROMPT_FORMAT)); + outputFormatCombo.setCellFactory(ignored -> new javafx.scene.control.ListCell<>() { + @Override + protected void updateItem(OutputFormatCatalog item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : item.displayName()); + } + }); + outputFormatCombo.setButtonCell(new javafx.scene.control.ListCell<>() { + @Override + protected void updateItem(OutputFormatCatalog item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : item.displayName()); + } + }); + outputFormatCombo.valueProperty().addListener((ignored, oldValue, newValue) -> { + if (!Objects.equals(oldValue, newValue)) { + refreshOutputCodecs(); + } + }); + } + + private void configureOutputCodecCombo() { + outputCodecCombo.setMaxWidth(Double.MAX_VALUE); + outputCodecCombo.setPromptText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_PROMPT_CODEC)); + outputCodecCombo.setCellFactory(ignored -> new javafx.scene.control.ListCell<>() { + @Override + protected void updateItem(OutputCodecCatalog item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : item.displayName()); + } + }); + outputCodecCombo.setButtonCell(new javafx.scene.control.ListCell<>() { + @Override + protected void updateItem(OutputCodecCatalog item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : item.displayName()); + } + }); + } + + private void renderStep() { + final boolean rootStep = stepIndex == 0; + backButton.setDisable(rootStep || creating); + nextButton.setDisable(creating); + createButton.setDisable(creating); + nextButton.setVisible(stepIndex < 1); + nextButton.setManaged(stepIndex < 1); + createButton.setVisible(stepIndex == 1); + createButton.setManaged(stepIndex == 1); + + switch (stepIndex) { + case 0 -> renderRootStep(); + case 1 -> renderDetailsStep(); + default -> throw new IllegalStateException("unknown add asset wizard 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 rootLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_ROOT)); + final Button browseButton = new Button(); + browseButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_BROWSE)); + browseButton.getStyleClass().addAll("studio-button", "studio-button-secondary"); + browseButton.setDisable(creating); + browseButton.setOnAction(ignored -> browseForRoot()); + + final HBox row = new HBox(12, assetRootField, browseButton); + HBox.setHgrow(assetRootField, Priority.ALWAYS); + + final Label assetsRootHint = new Label(Container.i18n().format( + I18n.ASSETS_ADD_WIZARD_ASSETS_ROOT_HINT, + projectReference.rootPath().resolve("assets"))); + assetsRootHint.getStyleClass().add("studio-launcher-subtitle"); + + stepBody.getChildren().setAll(rootLabel, row, assetsRootHint); + } + + private void renderDetailsStep() { + stepTitle.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_STEP_DETAILS_TITLE)); + stepDescription.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_STEP_DETAILS_DESCRIPTION)); + + final Label nameLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_NAME)); + final Label typeLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_TYPE)); + final Label formatLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_FORMAT)); + final Label codecLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_CODEC)); + final Label preloadLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_PRELOAD)); + final Label noteLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_NOTE)); + noteLabel.setWrapText(true); + noteLabel.getStyleClass().add("studio-launcher-subtitle"); + + preloadCheckBox.setText(""); + stepBody.getChildren().setAll( + new VBox(6, nameLabel, assetNameField), + new VBox(6, typeLabel, assetFamilyCombo), + new VBox(6, formatLabel, outputFormatCombo), + new VBox(6, codecLabel, outputCodecCombo), + new VBox(6, preloadLabel, preloadCheckBox), + noteLabel); + } + + private void goBack() { + if (stepIndex == 0 || creating) { + return; + } + feedbackLabel.setText(""); + stepIndex -= 1; + renderStep(); + } + + private void goNext() { + if (creating || !validateCurrentStep()) { + return; + } + feedbackLabel.setText(""); + stepIndex += 1; + renderStep(); + } + + private boolean validateCurrentStep() { + return switch (stepIndex) { + case 0 -> validateRoot(); + case 1 -> validateDetails(); + default -> true; + }; + } + + private boolean validateRoot() { + final String relativeRoot = normalizedRelativeRoot(assetRootField.getText()); + if (relativeRoot == null) { + feedbackLabel.setText(Container.i18n().text( + assetRootField.getText().trim().isBlank() + ? I18n.ASSETS_ADD_WIZARD_ERROR_ROOT + : I18n.ASSETS_ADD_WIZARD_ERROR_ROOT_RELATIVE)); + return false; + } + final Path assetRoot = ensureAssetsRoot().resolve(relativeRoot).toAbsolutePath().normalize(); + if (Files.isRegularFile(assetRoot.resolve("asset.json"))) { + feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_ROOT_ALREADY_ASSET)); + return false; + } + return true; + } + + private boolean validateDetails() { + if (assetNameField.getText().trim().isBlank()) { + feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_NAME)); + return false; + } + if (selectedFamily() == null || selectedFamily() == AssetFamilyCatalog.UNKNOWN) { + feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_TYPE)); + return false; + } + if (selectedFormat() == null || selectedFormat() == OutputFormatCatalog.UNKNOWN) { + feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_FORMAT)); + return false; + } + if (selectedCodec() == null || selectedCodec() == OutputCodecCatalog.UNKNOWN) { + feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_CODEC)); + return false; + } + if (!selectedFormat().supports(selectedCodec())) { + feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_UNSUPPORTED_COMBINATION)); + return false; + } + return true; + } + + private void browseForRoot() { + final Path assetsRoot = ensureAssetsRoot(); + final DirectoryChooser chooser = new DirectoryChooser(); + chooser.setTitle(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_BROWSE_TITLE)); + chooser.setInitialDirectory((assetsRoot.toFile().isDirectory() ? assetsRoot : projectReference.rootPath()).toFile()); + final File selected = chooser.showDialog(stage); + if (selected == null) { + return; + } + final Path selectedPath = selected.toPath().toAbsolutePath().normalize(); + if (!selectedPath.startsWith(assetsRoot)) { + feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_ROOT_OUTSIDE_ASSETS)); + return; + } + if (Files.isRegularFile(selectedPath.resolve("asset.json"))) { + feedbackLabel.setText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_ROOT_ALREADY_ASSET)); + return; + } + assetRootField.setText(assetsRoot.relativize(selectedPath).toString().replace('\\', '/')); + } + + private void finishCreate() { + if (creating || !validateRoot() || !validateDetails()) { + return; + } + + creating = true; + feedbackLabel.setText(""); + renderStep(); + final CreateAssetRequest request = new CreateAssetRequest( + projectReference.toPackerProjectContext(), + normalizedRelativeRoot(assetRootField.getText()), + assetNameField.getText().trim(), + selectedFamily(), + selectedFormat(), + selectedCodec(), + preloadCheckBox.isSelected()); + Container.backgroundTasks().submit(() -> createAsset(request)); + } + + private void createAsset(CreateAssetRequest request) { + try { + final CreateAssetResult createResult = Container.packer().workspaceService().createAsset(request); + Platform.runLater(() -> applyCreateResult(createResult)); + } catch (RuntimeException exception) { + Platform.runLater(() -> applyCreateFailure(exception)); + } + } + + private void applyCreateResult(CreateAssetResult createResult) { + creating = false; + if (createResult.status() == PackerOperationStatus.SUCCESS && createResult.assetReference() != null) { + result.set(createResult.assetReference()); + stage.close(); + return; + } + feedbackLabel.setText(Objects.requireNonNullElse(createResult.summary(), "Unable to create asset.")); + renderStep(); + } + + private void applyCreateFailure(RuntimeException exception) { + creating = false; + feedbackLabel.setText(exception.getMessage() == null || exception.getMessage().isBlank() + ? "Unable to create asset." + : exception.getMessage()); + renderStep(); + } + + private void refreshOutputFormats() { + outputFormatCombo.setItems(FXCollections.observableArrayList(OutputFormatCatalog.supportedFor(selectedFamily()))); + if (!outputFormatCombo.getItems().isEmpty()) { + outputFormatCombo.getSelectionModel().selectFirst(); + } else { + outputFormatCombo.getSelectionModel().clearSelection(); + } + refreshOutputCodecs(); + } + + private void refreshOutputCodecs() { + final OutputFormatCatalog selectedFormat = selectedFormat(); + outputCodecCombo.setItems(FXCollections.observableArrayList( + selectedFormat == null ? java.util.List.of() : selectedFormat.availableCodecs())); + if (!outputCodecCombo.getItems().isEmpty()) { + outputCodecCombo.getSelectionModel().selectFirst(); + } else { + outputCodecCombo.getSelectionModel().clearSelection(); + } + } + + private AssetFamilyCatalog selectedFamily() { + return assetFamilyCombo.getValue(); + } + + private OutputFormatCatalog selectedFormat() { + return outputFormatCombo.getValue(); + } + + private OutputCodecCatalog selectedCodec() { + return outputCodecCombo.getValue(); + } + + private String normalizedRelativeRoot(String candidate) { + final String raw = Objects.requireNonNullElse(candidate, "").trim().replace('\\', '/'); + if (raw.isBlank()) { + return null; + } + final Path normalized = Path.of(raw).normalize(); + if (normalized.isAbsolute() || normalized.startsWith("..")) { + return null; + } + final String value = normalized.toString().replace('\\', '/'); + return value.isBlank() ? null : value; + } + + private Path ensureAssetsRoot() { + final Path assetsRoot = projectReference.rootPath().resolve("assets").toAbsolutePath().normalize(); + try { + Files.createDirectories(assetsRoot); + } catch (IOException exception) { + feedbackLabel.setText(exception.getMessage() == null || exception.getMessage().isBlank() + ? Container.i18n().text(I18n.ASSETS_ADD_WIZARD_ERROR_ROOT_OUTSIDE_ASSETS) + : exception.getMessage()); + } + return assetsRoot; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/builder/BuilderWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/builder/ShipperWorkspace.java similarity index 89% rename from prometeu-studio/src/main/java/p/studio/workspaces/builder/BuilderWorkspace.java rename to prometeu-studio/src/main/java/p/studio/workspaces/builder/ShipperWorkspace.java index 2e935a4c..628ce6df 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/builder/BuilderWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/builder/ShipperWorkspace.java @@ -14,30 +14,14 @@ import p.studio.utilities.logs.LogAggregator; import p.studio.workspaces.Workspace; import p.studio.workspaces.WorkspaceId; -public class BuilderWorkspace implements Workspace { +public class ShipperWorkspace extends Workspace { private final BorderPane root = new BorderPane(); private final TextArea logs = new TextArea(); private final Button buildButton = new Button(); private final Button clearButton = new Button(); - private final ProjectReference projectReference; - @Override - public WorkspaceId id() { - return WorkspaceId.SHIPPER; - } - - @Override - public I18n title() { - return I18n.WORKSPACE_SHIPPER; - } - - @Override - public Node root() { - return root; - } - - public BuilderWorkspace(ProjectReference projectReference) { - this.projectReference = projectReference; + public ShipperWorkspace(ProjectReference projectReference) { + super(projectReference); final var toolbar = buildToolBar(); root.setTop(toolbar); @@ -51,6 +35,31 @@ public class BuilderWorkspace implements Workspace { root.setCenter(split); } + @Override + public WorkspaceId workspaceId() { + return WorkspaceId.SHIPPER; + } + + @Override + public I18n title() { + return I18n.WORKSPACE_SHIPPER; + } + + @Override + public Node rootNode() { + return root; + } + + @Override + public void load() { + + } + + @Override + public void unLoad() { + + } + private ToolBar buildToolBar() { buildButton.textProperty().bind(Container.i18n().bind(I18n.WORKSPACE_SHIPPER_BUTTON_RUN)); buildButton.getStyleClass().addAll("studio-button", "studio-button-primary"); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java index 67fe3bae..ae154eaa 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java @@ -4,16 +4,18 @@ import javafx.scene.Node; import javafx.scene.layout.BorderPane; import org.fxmisc.richtext.CodeArea; import org.fxmisc.richtext.LineNumberFactory; +import p.studio.projects.ProjectReference; import p.studio.utilities.i18n.I18n; import p.studio.workspaces.Workspace; import p.studio.workspaces.WorkspaceId; -public final class EditorWorkspace implements Workspace { +public final class EditorWorkspace extends Workspace { private final BorderPane root = new BorderPane(); private final EditorToolbar toolbar = new EditorToolbar(); private final CodeArea codeArea = new CodeArea(); - public EditorWorkspace() { + public EditorWorkspace(final ProjectReference projectReference) { + super(projectReference); codeArea.setParagraphGraphicFactory(LineNumberFactory.get(codeArea)); codeArea.replaceText(""" fn frame(): void @@ -26,9 +28,19 @@ public final class EditorWorkspace implements Workspace { root.setCenter(codeArea); } - @Override public WorkspaceId id() { return WorkspaceId.EDITOR; } + @Override public WorkspaceId workspaceId() { return WorkspaceId.EDITOR; } @Override public I18n title() { return I18n.WORKSPACE_CODE; } - @Override public Node root() { return root; } + @Override public Node rootNode() { return root; } + + @Override + public void load() { + + } + + @Override + public void unLoad() { + + } public CodeArea codeArea() { return codeArea; } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/framework/StudioEventAware.java b/prometeu-studio/src/main/java/p/studio/workspaces/framework/StudioEventAware.java new file mode 100644 index 00000000..ce6c0d64 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/framework/StudioEventAware.java @@ -0,0 +1,19 @@ +package p.studio.workspaces.framework; + +import p.studio.controls.lifecycle.StudioControlLifecycle; + +public interface StudioEventAware extends StudioControlLifecycle { + StudioEventBindings eventBindings(); + + void registerEventSubscriptions(); + + @Override + default void subscribe() { + eventBindings().activate(this::registerEventSubscriptions); + } + + @Override + default void unsubscribe() { + eventBindings().clear(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/framework/StudioEventBindings.java b/prometeu-studio/src/main/java/p/studio/workspaces/framework/StudioEventBindings.java new file mode 100644 index 00000000..a9aa4d9d --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/framework/StudioEventBindings.java @@ -0,0 +1,81 @@ +package p.studio.workspaces.framework; + +import p.studio.events.StudioEvent; +import p.studio.events.StudioEventBus; +import p.studio.events.StudioWorkspaceEventBus; + +import java.util.Objects; +import java.util.function.Consumer; + +public final class StudioEventBindings { + private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag(); + + public void activate(Runnable registration) { + Objects.requireNonNull(registration, "registration"); + if (!subscriptions.isEmpty()) { + return; + } + registration.run(); + } + + public void listen( + StudioEventBus eventBus, + Class eventType, + Consumer consumer) { + listen(eventBus, eventType).handle(consumer); + } + + public void listen( + StudioWorkspaceEventBus eventBus, + Class eventType, + Consumer consumer) { + listen(eventBus, eventType).handle(consumer); + } + + public Registration listen( + StudioEventBus eventBus, + Class eventType) { + return new Registration<>( + Objects.requireNonNull(eventBus, "eventBus"), + Objects.requireNonNull(eventType, "eventType")); + } + + public Registration listen( + StudioWorkspaceEventBus eventBus, + Class eventType) { + return new Registration<>( + Objects.requireNonNull(eventBus, "eventBus"), + Objects.requireNonNull(eventType, "eventType")); + } + + public void clear() { + subscriptions.clear(); + } + + public final class Registration { + private final StudioEventBus studioEventBus; + private final StudioWorkspaceEventBus workspaceEventBus; + private final Class eventType; + + private Registration(StudioEventBus studioEventBus, Class eventType) { + this.studioEventBus = studioEventBus; + this.workspaceEventBus = null; + this.eventType = eventType; + } + + private Registration(StudioWorkspaceEventBus workspaceEventBus, Class eventType) { + this.studioEventBus = null; + this.workspaceEventBus = workspaceEventBus; + this.eventType = eventType; + } + + public void handle(Consumer consumer) { + final Consumer resolvedConsumer = Objects.requireNonNull(consumer, "consumer"); + if (studioEventBus != null) { + subscriptions.add(studioEventBus.subscribe(eventType, resolvedConsumer)); + return; + } + subscriptions.add(workspaceEventBus.subscribe(eventType, resolvedConsumer)); + } + } +} diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index 78d247e3..9bd3e465 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -69,12 +69,9 @@ workspace.shipper.button.clear=Clear workspace.assets=Assets assets.navigator.title=Asset Navigator +assets.navigator.action.refresh=Refresh assets.navigator.action.add=Add Asset -assets.navigator.action.doctor=Doctor -assets.navigator.action.pack=Pack -assets.details.title=Selected Asset assets.search.prompt=Search assets by name or path -assets.filter.registered=Registered assets.filter.unregistered=Unregistered assets.filter.diagnostics=Diagnostics assets.filter.preload=Preload @@ -89,6 +86,7 @@ assets.badge.preload=Preload assets.badge.diagnostics=Diagnostics assets.section.summary=Summary assets.section.runtimeContract=Runtime Contract +assets.subsection.codecConfiguration=Codec Configuration assets.section.inputsPreview=Inputs / Preview assets.section.diagnostics=Diagnostics assets.section.actions=Actions @@ -115,12 +113,17 @@ assets.mutation.cancel=Cancel assets.mutation.apply=Apply assets.mutation.confirm.title=Confirm Mutation assets.mutation.confirm.header=Confirm {0} +form.action.change=Change +form.action.apply=Apply +form.action.reset=Reset +form.action.cancel=Cancel assets.label.name=Name assets.label.registration=Registration assets.label.buildParticipation=Build Participation assets.label.assetId=Asset ID assets.label.type=Type assets.label.location=Location +assets.label.bank=Bank assets.label.targetLocation=Target Location assets.label.format=Format assets.label.codec=Codec @@ -153,6 +156,7 @@ assets.details.loading=Waiting for asset data... assets.details.empty=Create or add assets to this project to start using the Assets workspace. assets.details.ready=Selected asset: {0}\nState: {1}\nRoot: {2} assets.details.noSelection=Select an asset from the navigator once assets are available. +assets.details.codecConfiguration.empty=This codec does not expose configuration fields yet. 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 @@ -176,6 +180,9 @@ assets.addWizard.note=This preload flag can be changed later by editing asset.js 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.rootRelative=Asset root must be a relative path inside assets/. +assets.addWizard.error.rootOutsideAssets=The selected directory must stay inside assets/. +assets.addWizard.error.rootAlreadyAsset=The selected root already contains asset.json. 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. diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index b14ab44f..648940ea 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -139,6 +139,18 @@ -fx-text-fill: #eef6ff; } +.studio-button.studio-button-add-asset { + -fx-background-color: #1d4f37; + -fx-border-color: #4db680; + -fx-text-fill: #f0fff6; +} + +.studio-button.studio-button-add-asset:hover { + -fx-background-color: #256546; + -fx-border-color: #72d9a1; + -fx-text-fill: #fbfffd; +} + .studio-button.studio-button-warning { -fx-background-color: #4a3816; -fx-border-color: #c79a3d; @@ -196,6 +208,11 @@ -fx-background-color: #404047; } +.studio-form-actions { + -fx-alignment: center-right; + -fx-padding: 8 0 0 0; +} + .studio-workspace-rail-button { -fx-font-size: 16px; } @@ -288,12 +305,23 @@ -fx-padding: 0 0 0 8; } +.assets-workspace-action-button { + -fx-alignment: center; + -fx-pref-width: 118px; + -fx-min-width: 118px; +} + .assets-workspace-pane-title { -fx-text-fill: #f3f7fb; -fx-font-size: 15px; -fx-font-weight: bold; } +.assets-workspace-details-title { + -fx-text-fill: #9ecbff; + -fx-font-size: 20px; +} + .assets-workspace-search { -fx-background-color: #101419; -fx-text-fill: #f2f7fb; @@ -499,8 +527,24 @@ } .assets-details-summary-actions-row > .assets-details-section:last-child { - -fx-pref-width: 280; - -fx-max-width: 320; + -fx-min-width: 0; +} + +.assets-details-summary-actions-row > .assets-details-section { + -fx-alignment: top-left; +} + +.assets-details-actions-scroll { + -fx-background-color: transparent; + -fx-fit-to-width: true; +} + +.assets-details-actions-scroll > .viewport { + -fx-background-color: transparent; +} + +.assets-details-actions-content { + -fx-spacing: 10; } .assets-details-section { @@ -532,6 +576,8 @@ -fx-text-fill: #8fa5bc; -fx-font-size: 11px; -fx-min-width: 88; + -fx-pref-width: 88; + -fx-max-width: 88; -fx-font-weight: bold; } @@ -540,6 +586,113 @@ -fx-font-size: 12px; } +.assets-details-combo { + -fx-background-color: #0f1318; + -fx-border-color: #334150; + -fx-border-radius: 8; + -fx-background-radius: 8; + -fx-text-fill: #eef4fb; +} + +.assets-details-contract-body { + -fx-alignment: top-left; + -fx-spacing: 16; +} + +.assets-details-contract-section { + -fx-min-height: 372; + -fx-pref-height: 372; + -fx-max-height: 372; +} + +.assets-details-contract-column { + -fx-spacing: 8; + -fx-min-width: 0; +} + +.assets-details-contract-codec-column { + -fx-spacing: 10; +} + +.assets-details-contract-metadata-scroll { + -fx-background-color: #0f1318; + -fx-background-radius: 10; + -fx-border-color: #2f3a47; + -fx-border-radius: 10; + -fx-pref-height: 198; + -fx-min-height: 198; + -fx-max-height: 198; +} + +.assets-details-contract-metadata-scroll > .viewport { + -fx-background-color: transparent; +} + +.assets-details-contract-metadata-content { + -fx-padding: 10; + -fx-spacing: 8; +} + +.assets-details-chips { + -fx-alignment: center-left; +} + +.assets-details-chip { + -fx-font-size: 10px; + -fx-padding: 3 7 3 7; + -fx-background-radius: 999; + -fx-border-radius: 999; + -fx-border-width: 1; +} + +.assets-details-chip-image { + -fx-background-color: #11283a; + -fx-border-color: #5cb6ff; + -fx-text-fill: #d7f0ff; +} + +.assets-details-chip-palette { + -fx-background-color: #2f2121; + -fx-border-color: #ff9b7a; + -fx-text-fill: #ffe2d7; +} + +.assets-details-chip-audio { + -fx-background-color: #1e2741; + -fx-border-color: #85a8ff; + -fx-text-fill: #dde6ff; +} + +.assets-details-chip-generic { + -fx-background-color: #1e242c; + -fx-border-color: #5f7388; + -fx-text-fill: #d5dde7; +} + +.assets-details-chip-registered { + -fx-background-color: #193022; + -fx-border-color: #53c38a; + -fx-text-fill: #d9ffe8; +} + +.assets-details-chip-unregistered { + -fx-background-color: #3a2d14; + -fx-border-color: #bc8a31; + -fx-text-fill: #ffd27a; +} + +.assets-details-chip-included { + -fx-background-color: #271747; + -fx-border-color: #7f65cf; + -fx-text-fill: #d9cbff; +} + +.assets-details-chip-excluded { + -fx-background-color: #4a1a1c; + -fx-border-color: #cf6268; + -fx-text-fill: #ffb4b8; +} + .assets-details-readonly-check { -fx-text-fill: #eef4fb; -fx-font-size: 12px; diff --git a/prometeu-studio/src/test/java/p/studio/controls/forms/StudioFormSessionTest.java b/prometeu-studio/src/test/java/p/studio/controls/forms/StudioFormSessionTest.java new file mode 100644 index 00000000..eb330b6f --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/controls/forms/StudioFormSessionTest.java @@ -0,0 +1,63 @@ +package p.studio.controls.forms; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class StudioFormSessionTest { + @Test + void resetDraftKeepsEditingModeAndClearsDirtyState() { + final StudioFormSession session = new StudioFormSession<>("source"); + + session.beginEdit(); + session.setDraft("draft"); + + session.resetDraft(); + + assertEquals(StudioFormMode.EDITING, session.mode()); + assertEquals("source", session.draft()); + assertFalse(session.isDirty()); + } + + @Test + void cancelEditResetsDraftAndReturnsToReadOnly() { + final StudioFormSession session = new StudioFormSession<>("source"); + + session.beginEdit(); + session.setDraft("draft"); + + session.cancelEdit(); + + assertEquals(StudioFormMode.READ_ONLY, session.mode()); + assertEquals("source", session.draft()); + assertFalse(session.isDirty()); + } + + @Test + void replaceSourceResetsModeAndDraft() { + final StudioFormSession session = new StudioFormSession<>("source"); + + session.beginEdit(); + session.setDraft("draft"); + + session.replaceSource("next"); + + assertEquals(StudioFormMode.READ_ONLY, session.mode()); + assertEquals("next", session.source()); + assertEquals("next", session.draft()); + assertFalse(session.isDirty()); + } + + @Test + void updateDraftMarksSessionDirty() { + final StudioFormSession session = new StudioFormSession<>("source"); + + session.beginEdit(); + session.updateDraft(current -> current + "-edited"); + + assertTrue(session.isDirty()); + assertEquals("source-edited", session.draft()); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java b/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java index 027c1c41..4de80534 100644 --- a/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java +++ b/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java @@ -1,18 +1,11 @@ package p.studio.controls.shell; import org.junit.jupiter.api.Test; -import p.packer.api.events.PackerEventKind; -import p.studio.events.StudioAssetsMutationAppliedEvent; -import p.studio.events.StudioAssetsMutationFailedEvent; -import p.studio.events.StudioAssetsMutationPreviewReadyEvent; +import p.packer.events.PackerEventKind; import p.studio.events.StudioPackerOperationEvent; -import p.studio.events.StudioAssetsWorkspaceRefreshFailedEvent; -import p.studio.events.StudioAssetsWorkspaceRefreshedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshFailedEvent; +import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceRefreshedEvent; import p.studio.events.StudioProjectOpenedEvent; -import p.studio.projects.ProjectReference; -import p.studio.workspaces.assets.AssetWorkspaceAction; - -import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -21,18 +14,18 @@ final class StudioActivityEventMapperTest { @Test void mapsProjectOpenedToSuccessEntry() { final StudioActivityEntry entry = StudioActivityEventMapper - .map(new StudioProjectOpenedEvent(project())) + .map(new StudioProjectOpenedEvent()) .orElseThrow(); assertEquals("Studio", entry.source()); assertEquals(StudioActivityEntrySeverity.SUCCESS, entry.severity()); - assertTrue(entry.message().contains("Project opened")); + assertEquals("Project opened", entry.message()); } @Test void mapsAssetsRefreshedToSuccessEntry() { final StudioActivityEntry entry = StudioActivityEventMapper - .map(new StudioAssetsWorkspaceRefreshedEvent(project(), 7)) + .map(new StudioAssetsWorkspaceRefreshedEvent(7)) .orElseThrow(); assertEquals("Assets", entry.source()); @@ -43,7 +36,7 @@ final class StudioActivityEventMapperTest { @Test void mapsRefreshFailureToStickyErrorEntry() { final StudioActivityEntry entry = StudioActivityEventMapper - .map(new StudioAssetsWorkspaceRefreshFailedEvent(project(), "Refresh failed")) + .map(new StudioAssetsWorkspaceRefreshFailedEvent("Refresh failed")) .orElseThrow(); assertEquals(StudioActivityEntrySeverity.ERROR, entry.severity()); @@ -52,51 +45,13 @@ final class StudioActivityEventMapperTest { } @Test - void mapsMutationPreviewReadyToInfoEntry() { + void mapsPackerWriteEventToActivityEntry() { final StudioActivityEntry entry = StudioActivityEventMapper - .map(new StudioAssetsMutationPreviewReadyEvent(project(), AssetWorkspaceAction.RELOCATE, 1)) - .orElseThrow(); - - assertEquals("Assets", entry.source()); - assertEquals(StudioActivityEntrySeverity.INFO, entry.severity()); - assertEquals("Preview ready: relocate", entry.message()); - } - - @Test - void mapsMutationAppliedToSuccessEntry() { - final StudioActivityEntry entry = StudioActivityEventMapper - .map(new StudioAssetsMutationAppliedEvent(project(), AssetWorkspaceAction.RELOCATE, 1)) + .map(new StudioPackerOperationEvent("op-1", PackerEventKind.ACTION_APPLIED, "Asset created.", null, false)) .orElseThrow(); assertEquals("Assets", entry.source()); assertEquals(StudioActivityEntrySeverity.SUCCESS, entry.severity()); - assertEquals("Action applied: relocate", entry.message()); - } - - @Test - void mapsMutationFailureToStickyErrorEntry() { - final StudioActivityEntry entry = StudioActivityEventMapper - .map(new StudioAssetsMutationFailedEvent(project(), AssetWorkspaceAction.REMOVE, "Apply failed")) - .orElseThrow(); - - assertEquals("Assets", entry.source()); - assertEquals(StudioActivityEntrySeverity.ERROR, entry.severity()); - assertTrue(entry.sticky()); - assertEquals("Apply failed", entry.message()); - } - - @Test - void mapsPackerBuildEventToActivityEntry() { - final StudioActivityEntry entry = StudioActivityEventMapper - .map(new StudioPackerOperationEvent(project(), "op-1", PackerEventKind.BUILD_FINISHED, "Build finished.", 1.0d, false)) - .orElseThrow(); - - assertEquals("Assets", entry.source()); - assertEquals(StudioActivityEntrySeverity.SUCCESS, entry.severity()); - assertEquals("Build finished.", entry.message()); - } - - private ProjectReference project() { - return new ProjectReference("Main", "1.0.0", "pbs", 1, Path.of("/tmp/main")); + assertEquals("Asset created.", entry.message()); } } diff --git a/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityStorageServiceTest.java b/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityStorageServiceTest.java new file mode 100644 index 00000000..8fe5c8e6 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityStorageServiceTest.java @@ -0,0 +1,69 @@ +package p.studio.controls.shell; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.studio.projects.ProjectReference; +import p.studio.projects.ProjectStudioPaths; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class StudioActivityStorageServiceTest { + @TempDir + Path tempDir; + + @Test + void savesAndLoadsProjectScopedActivityHistory() throws Exception { + final StudioActivityStorageService service = new StudioActivityStorageService(); + final ProjectReference project = project("main"); + final List history = List.of( + new StudioActivityEntry("Studio", "Project opened", StudioActivityEntrySeverity.SUCCESS, false), + new StudioActivityEntry("Assets", "3 assets loaded", StudioActivityEntrySeverity.INFO, false)); + + service.save(project, history); + + assertTrue(Files.isRegularFile(ProjectStudioPaths.activitiesPath(project))); + assertEquals(history, service.load(project)); + } + + @Test + void trimsPersistedHistoryToConfiguredMaximum() { + final StudioActivityStorageService service = new StudioActivityStorageService(); + final ProjectReference project = project("main"); + final List history = new ArrayList<>(); + for (int index = 0; index < StudioActivityStorageService.MAX_HISTORY + 25; index += 1) { + history.add(new StudioActivityEntry( + "Assets", + "Entry " + index, + StudioActivityEntrySeverity.INFO, + false)); + } + + service.save(project, history); + + final List loaded = service.load(project); + assertEquals(StudioActivityStorageService.MAX_HISTORY, loaded.size()); + assertEquals("Entry 0", loaded.getFirst().message()); + assertEquals("Entry 499", loaded.getLast().message()); + } + + @Test + void malformedStorageFallsBackToEmptyHistory() throws Exception { + final StudioActivityStorageService service = new StudioActivityStorageService(); + final ProjectReference project = project("main"); + Files.createDirectories(ProjectStudioPaths.studioRoot(project)); + Files.writeString(ProjectStudioPaths.activitiesPath(project), "{ not valid json"); + + assertEquals(List.of(), service.load(project)); + } + + private ProjectReference project(String name) { + final Path projectRoot = tempDir.resolve(name); + return new ProjectReference("Main", "1.0.0", "pbs", 1, projectRoot); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/events/StudioPackerEventAdapterTest.java b/prometeu-studio/src/test/java/p/studio/events/StudioPackerEventAdapterTest.java index 75a75236..75087921 100644 --- a/prometeu-studio/src/test/java/p/studio/events/StudioPackerEventAdapterTest.java +++ b/prometeu-studio/src/test/java/p/studio/events/StudioPackerEventAdapterTest.java @@ -1,68 +1,58 @@ package p.studio.events; import org.junit.jupiter.api.Test; -import p.packer.api.events.PackerEvent; -import p.packer.api.events.PackerEventKind; -import p.packer.api.events.PackerProgress; -import p.studio.projects.ProjectReference; -import p.studio.workspaces.WorkspaceId; +import p.packer.events.PackerEvent; +import p.packer.events.PackerEventKind; +import p.packer.events.PackerProgress; -import java.nio.file.Path; import java.time.Instant; import java.util.ArrayList; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; final class StudioPackerEventAdapterTest { @Test - void forwardsBuildEventsIntoStudioOperationEvents() { + void forwardsScanEventsIntoStudioOperationEvents() { final StudioEventBus globalBus = new StudioEventBus(); final List events = new ArrayList<>(); globalBus.subscribe(StudioPackerOperationEvent.class, events::add); - final StudioPackerEventAdapter adapter = new StudioPackerEventAdapter( - new StudioWorkspaceEventBus(WorkspaceId.ASSETS, globalBus), - project()); + final StudioPackerEventAdapter adapter = new StudioPackerEventAdapter(globalBus); adapter.publish(new PackerEvent( "main", "op-1", 0L, - PackerEventKind.BUILD_STARTED, + PackerEventKind.ASSET_DISCOVERED, Instant.now(), - "Build started.", + "Discovered asset.", new PackerProgress(0.25d, false), List.of("ui_atlas"))); assertEquals(1, events.size()); - assertEquals(PackerEventKind.BUILD_STARTED, events.getFirst().kind()); + assertEquals(PackerEventKind.ASSET_DISCOVERED, events.getFirst().kind()); assertEquals(0.25d, events.getFirst().progress()); } @Test - void ignoresMutationLifecycleEventsAlreadyHandledByTypedStudioEvents() { + void forwardsWriteLifecycleEventsIntoStudioOperationEvents() { final StudioEventBus globalBus = new StudioEventBus(); final List events = new ArrayList<>(); globalBus.subscribe(StudioPackerOperationEvent.class, events::add); - final StudioPackerEventAdapter adapter = new StudioPackerEventAdapter( - new StudioWorkspaceEventBus(WorkspaceId.ASSETS, globalBus), - project()); + final StudioPackerEventAdapter adapter = new StudioPackerEventAdapter(globalBus); adapter.publish(new PackerEvent( "main", "op-1", 0L, - PackerEventKind.PREVIEW_READY, + PackerEventKind.ACTION_APPLIED, Instant.now(), - "Preview ready.", + "Asset created.", null, List.of("ui_atlas"))); - assertTrue(events.isEmpty()); - } - - private ProjectReference project() { - return new ProjectReference("Main", "1.0.0", "pbs", 1, Path.of("/tmp/main")); + assertEquals(1, events.size()); + assertEquals(PackerEventKind.ACTION_APPLIED, events.getFirst().kind()); + assertEquals("Asset created.", events.getFirst().summary()); } } diff --git a/prometeu-studio/src/test/java/p/studio/events/StudioWorkspaceEventBusTest.java b/prometeu-studio/src/test/java/p/studio/events/StudioWorkspaceEventBusTest.java index 11115bfe..e7863344 100644 --- a/prometeu-studio/src/test/java/p/studio/events/StudioWorkspaceEventBusTest.java +++ b/prometeu-studio/src/test/java/p/studio/events/StudioWorkspaceEventBusTest.java @@ -1,10 +1,8 @@ package p.studio.events; import org.junit.jupiter.api.Test; -import p.studio.projects.ProjectReference; import p.studio.workspaces.WorkspaceId; -import java.nio.file.Path; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; @@ -48,12 +46,10 @@ final class StudioWorkspaceEventBusTest { final StudioEventBus globalBus = new StudioEventBus(); final StudioWorkspaceEventBus workspaceBus = new StudioWorkspaceEventBus(WorkspaceId.ASSETS, globalBus); final List globalReceived = new CopyOnWriteArrayList<>(); - final ProjectReference project = new ProjectReference("Main", "1.0.0", "pbs", 1, Path.of("/tmp/main")); globalBus.subscribe(StudioProjectLoadingProgressEvent.class, event -> globalReceived.add(event.phase())); workspaceBus.publish(new StudioProjectLoadingProgressEvent( - project, StudioProjectLoadingPhase.INITIALIZING_SERVICES, "Initializing services", 0.5d, diff --git a/prometeu-studio/src/test/java/p/studio/projects/ProjectCatalogServiceTest.java b/prometeu-studio/src/test/java/p/studio/projects/ProjectCatalogServiceTest.java index a8e64a29..d6076dec 100644 --- a/prometeu-studio/src/test/java/p/studio/projects/ProjectCatalogServiceTest.java +++ b/prometeu-studio/src/test/java/p/studio/projects/ProjectCatalogServiceTest.java @@ -43,6 +43,7 @@ final class ProjectCatalogServiceTest { assertTrue(Files.isDirectory(project.rootPath())); assertTrue(Files.isRegularFile(project.rootPath().resolve("prometeu.json"))); assertTrue(Files.isDirectory(project.rootPath().resolve(".workspace"))); + assertTrue(Files.isDirectory(project.rootPath().resolve(".studio"))); assertTrue(Files.isDirectory(project.rootPath().resolve("src"))); assertTrue(Files.isDirectory(project.rootPath().resolve("build"))); assertTrue(Files.isDirectory(project.rootPath().resolve("cartridge"))); @@ -63,6 +64,7 @@ final class ProjectCatalogServiceTest { assertTrue(Files.isDirectory(project.rootPath())); assertTrue(Files.isRegularFile(project.rootPath().resolve("prometeu.json"))); assertTrue(Files.isDirectory(project.rootPath().resolve(".workspace"))); + assertTrue(Files.isDirectory(project.rootPath().resolve(".studio"))); assertTrue(Files.isDirectory(project.rootPath().resolve("src"))); } diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetCreationCatalogTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetCreationCatalogTest.java deleted file mode 100644 index 81d7b3cb..00000000 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetCreationCatalogTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package p.studio.workspaces.assets; - -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -final class AssetCreationCatalogTest { - @Test - void outputFormatsDependOnAssetType() { - assertEquals(List.of("TILES/indexed_v1", "SPRITES/indexed_v1"), AssetCreationCatalog.outputFormatsFor("image_bank")); - assertEquals(List.of("AUDIO/pcm_v1"), AssetCreationCatalog.outputFormatsFor("sound_bank")); - } - - @Test - void outputCodecsDependOnFormat() { - assertEquals(List.of("RAW"), AssetCreationCatalog.outputCodecsFor("image_bank", "TILES/indexed_v1")); - assertTrue(AssetCreationCatalog.supports("palette_bank", "PALETTE/indexed_v1", "RAW")); - } -} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilderTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilderTest.java deleted file mode 100644 index 5099e215..00000000 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetNavigatorProjectionBuilderTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package p.studio.workspaces.assets; - -import org.junit.jupiter.api.Test; - -import java.nio.file.Path; -import java.util.EnumSet; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -final class AssetNavigatorProjectionBuilderTest { - @Test - void groupsAssetsByParentPath() { - final Path projectRoot = Path.of("/tmp/project"); - final Path assetsRoot = projectRoot.resolve("assets"); - final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build( - List.of( - registeredAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false), - unregisteredAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)), - projectRoot, - "", - EnumSet.noneOf(AssetNavigatorFilter.class)); - - assertEquals(2, projection.visibleAssetCount()); - assertEquals(List.of("assets/audio", "assets/ui"), projection.groups().stream().map(AssetNavigatorGroup::label).sorted().toList()); - } - - @Test - void registeredAndUnregisteredFiltersBehaveAsStateFilterSet() { - final Path projectRoot = Path.of("/tmp/project"); - final Path assetsRoot = projectRoot.resolve("assets"); - final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build( - List.of( - registeredAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false), - unregisteredAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)), - projectRoot, - "", - EnumSet.of(AssetNavigatorFilter.REGISTERED)); - - assertEquals(1, projection.visibleAssetCount()); - assertEquals("ui_atlas", projection.groups().getFirst().assets().getFirst().assetName()); - } - - @Test - void diagnosticsAndPreloadActAsAdditionalConstraints() { - final Path projectRoot = Path.of("/tmp/project"); - final Path assetsRoot = projectRoot.resolve("assets"); - final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build( - List.of( - registeredAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, true), - registeredAsset(2, "bg_tiles", "image_bank", assetsRoot.resolve("bg/tiles"), true, false), - registeredAsset(3, "voice_bank", "sound_bank", assetsRoot.resolve("audio/voice"), false, true)), - projectRoot, - "", - EnumSet.of(AssetNavigatorFilter.REGISTERED, AssetNavigatorFilter.PRELOAD, AssetNavigatorFilter.DIAGNOSTICS)); - - assertEquals(1, projection.visibleAssetCount()); - assertEquals("ui_atlas", projection.groups().getFirst().assets().getFirst().assetName()); - } - - @Test - void searchMatchesAssetNameAndPathContext() { - final Path projectRoot = Path.of("/tmp/project"); - final Path assetsRoot = projectRoot.resolve("assets"); - final List assets = List.of( - registeredAsset(1, "ui_atlas", "image_bank", assetsRoot.resolve("ui/atlas"), true, false), - unregisteredAsset("menu_sounds", "sound_bank", assetsRoot.resolve("audio/menu"), false, false)); - - final AssetNavigatorProjection byName = AssetNavigatorProjectionBuilder.build( - assets, - projectRoot, - "atlas", - EnumSet.noneOf(AssetNavigatorFilter.class)); - final AssetNavigatorProjection byPath = AssetNavigatorProjectionBuilder.build( - assets, - projectRoot, - "audio", - EnumSet.noneOf(AssetNavigatorFilter.class)); - - assertEquals(1, byName.visibleAssetCount()); - assertEquals("ui_atlas", byName.groups().getFirst().assets().getFirst().assetName()); - assertEquals(1, byPath.visibleAssetCount()); - assertEquals("menu_sounds", byPath.groups().getFirst().assets().getFirst().assetName()); - } - - private AssetWorkspaceAssetSummary registeredAsset( - int assetId, - String name, - String family, - Path root, - boolean preload, - boolean hasDiagnostics) { - return new AssetWorkspaceAssetSummary( - new AssetWorkspaceSelectionKey.ManagedAsset(assetId), - name, - AssetWorkspaceAssetState.REGISTERED, - AssetWorkspaceBuildParticipation.INCLUDED, - assetId, - family, - root, - preload, - hasDiagnostics); - } - - private AssetWorkspaceAssetSummary unregisteredAsset( - String name, - String family, - Path root, - boolean preload, - boolean hasDiagnostics) { - return new AssetWorkspaceAssetSummary( - new AssetWorkspaceSelectionKey.OrphanAsset(root), - name, - AssetWorkspaceAssetState.UNREGISTERED, - AssetWorkspaceBuildParticipation.EXCLUDED, - null, - family, - root, - preload, - hasDiagnostics); - } -} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilderTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilderTest.java deleted file mode 100644 index b0da02d8..00000000 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilderTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package p.studio.workspaces.assets; - -import org.junit.jupiter.api.Test; - -import java.nio.file.Path; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -final class AssetWorkspaceActionSetBuilderTest { - @Test - void includedRegisteredAssetsExposeOnlySensitiveAssetMutations() { - final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(new AssetWorkspaceAssetSummary( - new AssetWorkspaceSelectionKey.ManagedAsset(42), - "ui_atlas", - AssetWorkspaceAssetState.REGISTERED, - AssetWorkspaceBuildParticipation.INCLUDED, - 42, - "image_bank", - Path.of("/tmp/assets/ui_atlas"), - true, - false)); - - assertEquals(List.of(), actionSet.primaryActions()); - assertEquals( - List.of( - AssetWorkspaceAction.EXCLUDE_FROM_BUILD, - AssetWorkspaceAction.RELOCATE, - AssetWorkspaceAction.REMOVE), - actionSet.sensitiveActions()); - } - - @Test - void excludedRegisteredAssetsExposeIncludeInBuildAndSensitiveMutations() { - final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(new AssetWorkspaceAssetSummary( - new AssetWorkspaceSelectionKey.ManagedAsset(42), - "ui_atlas", - AssetWorkspaceAssetState.REGISTERED, - AssetWorkspaceBuildParticipation.EXCLUDED, - 42, - "image_bank", - Path.of("/tmp/assets/ui_atlas"), - true, - false)); - - assertEquals(List.of(AssetWorkspaceAction.INCLUDE_IN_BUILD), actionSet.primaryActions()); - assertEquals(List.of(AssetWorkspaceAction.RELOCATE, AssetWorkspaceAction.REMOVE), actionSet.sensitiveActions()); - } - - @Test - void unregisteredAssetsExposeRegisterAndSensitiveRelocationFlows() { - final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(new AssetWorkspaceAssetSummary( - new AssetWorkspaceSelectionKey.OrphanAsset(Path.of("/tmp/assets/ui_sounds")), - "ui_sounds", - AssetWorkspaceAssetState.UNREGISTERED, - AssetWorkspaceBuildParticipation.EXCLUDED, - null, - "sound_bank", - Path.of("/tmp/assets/ui_sounds"), - false, - false)); - - assertEquals(List.of(AssetWorkspaceAction.REGISTER), actionSet.primaryActions()); - assertEquals(List.of(AssetWorkspaceAction.RELOCATE), actionSet.sensitiveActions()); - } -} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModelTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModelTest.java deleted file mode 100644 index 96137f09..00000000 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModelTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package p.studio.workspaces.assets; - -import org.junit.jupiter.api.Test; - -import java.nio.file.Path; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -final class AssetWorkspaceMutationImpactViewModelTest { - @Test - void splitsRegistryAndWorkspaceChangesForRendering() { - final AssetWorkspaceAssetSummary asset = new AssetWorkspaceAssetSummary( - new AssetWorkspaceSelectionKey.ManagedAsset(1), - "ui_atlas", - AssetWorkspaceAssetState.REGISTERED, - AssetWorkspaceBuildParticipation.INCLUDED, - 1, - "image_bank", - Path.of("/tmp/assets/ui/atlas"), - false, - false); - final AssetWorkspaceMutationPreview preview = new AssetWorkspaceMutationPreview( - AssetWorkspaceAction.RELOCATE, - asset, - List.of(), - List.of(), - List.of(), - List.of( - new AssetWorkspaceMutationChange(AssetWorkspaceMutationChangeScope.REGISTRY, "UPDATE", "ui/atlas -> ui/atlas-relocated"), - new AssetWorkspaceMutationChange(AssetWorkspaceMutationChangeScope.WORKSPACE, "MOVE", "ui/atlas -> ui/atlas-relocated")), - true, - Path.of("/tmp/assets/ui/atlas-relocated")); - - final AssetWorkspaceMutationImpactViewModel viewModel = AssetWorkspaceMutationImpactViewModel.from(preview); - - assertEquals(1, viewModel.registryChanges().size()); - assertEquals(1, viewModel.workspaceChanges().size()); - assertEquals("UPDATE", viewModel.registryChanges().getFirst().verb()); - assertEquals("MOVE", viewModel.workspaceChanges().getFirst().verb()); - } -} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceMutationUpdatePlannerTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceMutationUpdatePlannerTest.java deleted file mode 100644 index ffda3e88..00000000 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceMutationUpdatePlannerTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package p.studio.workspaces.assets; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -final class AssetWorkspaceMutationUpdatePlannerTest { - @Test - void classifiesLocalPatchMutations() { - assertEquals(AssetWorkspaceMutationUpdateStrategy.LOCAL_PATCH, AssetWorkspaceMutationUpdatePlanner.forSuccessfulAction(AssetWorkspaceAction.INCLUDE_IN_BUILD)); - assertEquals(AssetWorkspaceMutationUpdateStrategy.LOCAL_PATCH, AssetWorkspaceMutationUpdatePlanner.forSuccessfulAction(AssetWorkspaceAction.EXCLUDE_FROM_BUILD)); - } - - @Test - void classifiesStructuralSyncMutations() { - assertEquals(AssetWorkspaceMutationUpdateStrategy.STRUCTURAL_SYNC, AssetWorkspaceMutationUpdatePlanner.forSuccessfulAction(AssetWorkspaceAction.REGISTER)); - assertEquals(AssetWorkspaceMutationUpdateStrategy.STRUCTURAL_SYNC, AssetWorkspaceMutationUpdatePlanner.forSuccessfulAction(AssetWorkspaceAction.RELOCATE)); - assertEquals(AssetWorkspaceMutationUpdateStrategy.STRUCTURAL_SYNC, AssetWorkspaceMutationUpdatePlanner.forSuccessfulAction(AssetWorkspaceAction.REMOVE)); - } -} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspacePreviewScaleTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspacePreviewScaleTest.java deleted file mode 100644 index 20403372..00000000 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspacePreviewScaleTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package p.studio.workspaces.assets; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -final class AssetWorkspacePreviewScaleTest { - @Test - void keepsSmallImageAtOriginalScaleByDefault() { - assertEquals(1.0d, AssetWorkspace.previewScale(16.0d, 16.0d, 1)); - } - - @Test - void appliesIntegerZoomForSmallImages() { - assertEquals(8.0d, AssetWorkspace.previewScale(16.0d, 16.0d, 8)); - } - - @Test - void capsZoomOptionsByMaximumPreviewSize() { - assertEquals(6, AssetWorkspace.maxPreviewZoom(64.0d, 64.0d)); - assertEquals(1.0d, AssetWorkspace.previewScale(300.0d, 200.0d, 8)); - } - - @Test - void scalesLargeImagesDownToPreviewLimit() { - assertEquals(420.0d / 512.0d, AssetWorkspace.previewScale(512.0d, 128.0d, 1)); - } -} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceStateTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceStateTest.java deleted file mode 100644 index 039bb903..00000000 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceStateTest.java +++ /dev/null @@ -1,86 +0,0 @@ -package p.studio.workspaces.assets; - -import org.junit.jupiter.api.Test; - -import java.nio.file.Path; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -final class AssetWorkspaceStateTest { - @Test - void preservesRegisteredSelectionByAssetIdAcrossRefresh() { - final AssetWorkspaceSelectionKey.ManagedAsset selected = new AssetWorkspaceSelectionKey.ManagedAsset(42); - final List assets = List.of( - managedAsset(42, "ui_atlas", Path.of("/tmp/assets/ui_atlas")), - managedAsset(57, "ui_sounds", Path.of("/tmp/assets/ui_sounds"))); - - final AssetWorkspaceState state = AssetWorkspaceState.ready(assets, selected); - - assertEquals(AssetWorkspaceStatus.READY, state.status()); - assertEquals(selected, state.selectedKey()); - assertTrue(state.selectedAsset().isPresent()); - assertEquals("ui_atlas", state.selectedAsset().orElseThrow().assetName()); - } - - @Test - void preservesUnregisteredSelectionByAssetRootAcrossRefresh() { - final Path unregisteredRoot = Path.of("/tmp/assets/orphan_bank"); - final AssetWorkspaceSelectionKey.OrphanAsset selected = new AssetWorkspaceSelectionKey.OrphanAsset(unregisteredRoot); - final List assets = List.of( - unregisteredAsset("orphan_bank", unregisteredRoot), - unregisteredAsset("other_bank", Path.of("/tmp/assets/other_bank"))); - - final AssetWorkspaceState state = AssetWorkspaceState.ready(assets, selected); - - assertEquals(selected, state.selectedKey()); - assertEquals(unregisteredRoot.toAbsolutePath().normalize(), state.selectedAsset().orElseThrow().assetRoot()); - } - - @Test - void clearsToFirstAssetWhenPreferredSelectionDisappears() { - final AssetWorkspaceSelectionKey.ManagedAsset missing = new AssetWorkspaceSelectionKey.ManagedAsset(99); - final List assets = List.of( - managedAsset(42, "ui_atlas", Path.of("/tmp/assets/ui_atlas")), - managedAsset(57, "ui_sounds", Path.of("/tmp/assets/ui_sounds"))); - - final AssetWorkspaceState state = AssetWorkspaceState.ready(assets, missing); - - assertEquals(new AssetWorkspaceSelectionKey.ManagedAsset(42), state.selectedKey()); - } - - @Test - void emptySnapshotBecomesEmptyStateWithoutSelection() { - final AssetWorkspaceState state = AssetWorkspaceState.ready(List.of(), null); - - assertEquals(AssetWorkspaceStatus.EMPTY, state.status()); - assertNull(state.selectedKey()); - assertTrue(state.selectedAsset().isEmpty()); - } - - private AssetWorkspaceAssetSummary managedAsset(int assetId, String name, Path root) { - return new AssetWorkspaceAssetSummary( - new AssetWorkspaceSelectionKey.ManagedAsset(assetId), - name, - AssetWorkspaceAssetState.REGISTERED, - AssetWorkspaceBuildParticipation.INCLUDED, - assetId, - "image_bank", - root, - false, - false); - } - - private AssetWorkspaceAssetSummary unregisteredAsset(String name, Path root) { - return new AssetWorkspaceAssetSummary( - new AssetWorkspaceSelectionKey.OrphanAsset(root), - name, - AssetWorkspaceAssetState.UNREGISTERED, - AssetWorkspaceBuildParticipation.EXCLUDED, - null, - "image_bank", - root, - false, - false); - } -} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationServiceTest.java deleted file mode 100644 index 933a381d..00000000 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationServiceTest.java +++ /dev/null @@ -1,156 +0,0 @@ -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.*; - -final class FileSystemAssetWorkspaceMutationServiceTest { - @TempDir - Path tempDir; - - @Test - void previewRelocateForManagedAssetIsHighRiskAndPreservesIdentityContract() throws Exception { - final Path projectRoot = createManagedAssetProject(); - final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); - final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot); - final Path customTarget = projectRoot.resolve("assets/ui/atlas-v2"); - - final AssetWorkspaceMutationPreview preview = service.preview( - project("Main", projectRoot), - asset, - AssetWorkspaceAction.RELOCATE, - customTarget); - - assertTrue(preview.canApply()); - assertTrue(preview.highRisk()); - assertEquals(customTarget.toAbsolutePath().normalize(), preview.targetAssetRoot()); - assertTrue(preview.changes().stream().anyMatch(change -> - change.scope() == AssetWorkspaceMutationChangeScope.REGISTRY && change.verb().equals("UPDATE"))); - assertTrue(preview.changes().stream().anyMatch(change -> - change.scope() == AssetWorkspaceMutationChangeScope.WORKSPACE && change.verb().equals("MOVE"))); - } - - @Test - void applyExcludeFromBuildPreservesRegistrationAndUpdatesRegistryState() throws Exception { - final Path projectRoot = createManagedAssetProject(); - final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); - final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot); - final ProjectReference project = project("Main", projectRoot); - - final AssetWorkspaceMutationPreview preview = service.preview(project, asset, AssetWorkspaceAction.EXCLUDE_FROM_BUILD, null); - assertTrue(preview.canApply()); - - service.apply(project, 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")); - } - - @Test - void applyRelocateMovesAssetAndUpdatesRegistryRoot() throws Exception { - final Path projectRoot = createManagedAssetProject(); - final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); - final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot); - final ProjectReference project = project("Main", projectRoot); - final Path customTarget = projectRoot.resolve("assets/reorganized/atlas"); - - final AssetWorkspaceMutationPreview preview = service.preview(project, asset, AssetWorkspaceAction.RELOCATE, customTarget); - service.apply(project, preview); - - assertFalse(Files.exists(asset.assetRoot())); - assertTrue(Files.isDirectory(customTarget)); - final String registryJson = Files.readString(projectRoot.resolve("assets/.prometeu/index.json")); - assertTrue(registryJson.contains("\"asset_id\" : 1")); - 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 - void previewWithMissingAssetRootCreatesBlockerAndDisablesApply() { - final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); - final Path projectRoot = tempDir.resolve("main"); - final AssetWorkspaceAssetSummary asset = new AssetWorkspaceAssetSummary( - new AssetWorkspaceSelectionKey.OrphanAsset(projectRoot.resolve("assets/missing")), - "missing_asset", - AssetWorkspaceAssetState.UNREGISTERED, - AssetWorkspaceBuildParticipation.EXCLUDED, - null, - "unknown", - projectRoot.resolve("assets/missing"), - false, - false); - - final AssetWorkspaceMutationPreview preview = service.preview(project("Main", projectRoot), asset, AssetWorkspaceAction.RELOCATE); - - assertFalse(preview.canApply()); - assertFalse(preview.blockers().isEmpty()); - } - - private Path createManagedAssetProject() throws Exception { - final Path projectRoot = tempDir.resolve("main"); - final Path assetRoot = projectRoot.resolve("assets/ui/atlas"); - Files.createDirectories(assetRoot); - Files.createDirectories(projectRoot.resolve("assets/.prometeu")); - Files.writeString(assetRoot.resolve("asset.json"), """ - { - "name": "ui_atlas", - "type": "image_bank", - "preload": { "enabled": true } - } - """); - Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """ - { - "schema_version": 1, - "next_asset_id": 2, - "assets": [ - { - "asset_id": 1, - "asset_uuid": "uuid-1", - "root": "ui/atlas", - "included_in_build": true - } - ] - } - """); - return projectRoot; - } - - private AssetWorkspaceAssetSummary managedAsset(Path projectRoot) { - return new AssetWorkspaceAssetSummary( - new AssetWorkspaceSelectionKey.ManagedAsset(1), - "ui_atlas", - AssetWorkspaceAssetState.REGISTERED, - AssetWorkspaceBuildParticipation.INCLUDED, - 1, - "image_bank", - projectRoot.resolve("assets/ui/atlas"), - true, - false); - } - - private ProjectReference project(String name, Path root) { - return new ProjectReference(name, "1.0.0", "pbs", 1, root); - } -} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceServiceTest.java deleted file mode 100644 index 8e53ba8f..00000000 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceServiceTest.java +++ /dev/null @@ -1,142 +0,0 @@ -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 java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -final class FileSystemAssetWorkspaceServiceTest { - @TempDir - Path tempDir; - - @Test - void returnsEmptySnapshotWhenProjectHasNoAssetsDirectory() { - final FileSystemAssetWorkspaceService service = new FileSystemAssetWorkspaceService(); - - final AssetWorkspaceSnapshot snapshot = service.loadWorkspace(project("Empty Project", tempDir)); - - assertTrue(snapshot.assets().isEmpty()); - } - - @Test - void marksRegistryRootsAsRegisteredAndUnregisteredAnchorsAsExcluded() throws Exception { - final Path projectRoot = tempDir.resolve("main"); - final Path assetsRoot = projectRoot.resolve("assets"); - final Path managedRoot = assetsRoot.resolve("ui").resolve("atlas"); - final Path orphanRoot = assetsRoot.resolve("audio").resolve("ui_sounds"); - Files.createDirectories(managedRoot); - Files.createDirectories(orphanRoot); - Files.createDirectories(assetsRoot.resolve(".prometeu")); - - Files.writeString(managedRoot.resolve("asset.json"), """ - { - "name": "ui_atlas", - "type": "image_bank", - "preload": { "enabled": true } - } - """); - Files.writeString(orphanRoot.resolve("asset.json"), """ - { - "name": "ui_sounds", - "type": "sound_bank", - "preload": { "enabled": false } - } - """); - Files.writeString(assetsRoot.resolve(".prometeu").resolve("index.json"), """ - { - "schema_version": 1, - "next_asset_id": 2, - "assets": [ - { - "asset_id": 1, - "root": "ui/atlas" - } - ] - } - """); - - final FileSystemAssetWorkspaceService service = new FileSystemAssetWorkspaceService(); - - final AssetWorkspaceSnapshot snapshot = service.loadWorkspace(project("Main", projectRoot)); - - assertEquals(2, snapshot.assets().size()); - final AssetWorkspaceAssetSummary registered = snapshot.assets().stream() - .filter(asset -> asset.assetName().equals("ui_atlas")) - .findFirst() - .orElseThrow(); - final AssetWorkspaceAssetSummary unregistered = snapshot.assets().stream() - .filter(asset -> asset.assetName().equals("ui_sounds")) - .findFirst() - .orElseThrow(); - - assertEquals(AssetWorkspaceAssetState.REGISTERED, registered.state()); - assertEquals(AssetWorkspaceBuildParticipation.INCLUDED, registered.buildParticipation()); - assertEquals(1, registered.assetId()); - assertTrue(registered.preload()); - - assertEquals(AssetWorkspaceAssetState.UNREGISTERED, unregistered.state()); - assertEquals(AssetWorkspaceBuildParticipation.EXCLUDED, unregistered.buildParticipation()); - assertEquals(null, unregistered.assetId()); - } - - @Test - void loadsSelectedAssetDetailsIncludingContractAndInputs() throws Exception { - final Path projectRoot = tempDir.resolve("main"); - final Path assetsRoot = projectRoot.resolve("assets"); - final Path managedRoot = assetsRoot.resolve("ui").resolve("atlas"); - Files.createDirectories(managedRoot.resolve("sprites")); - Files.createDirectories(managedRoot.resolve("palettes")); - Files.createDirectories(assetsRoot.resolve(".prometeu")); - Files.writeString(managedRoot.resolve("sprites").resolve("confirm.png"), "fake-image"); - Files.writeString(managedRoot.resolve("palettes").resolve("ui_main.pal"), "00 11 22"); - Files.writeString(managedRoot.resolve("asset.json"), """ - { - "name": "ui_atlas", - "type": "image_bank", - "inputs": { - "sprites": ["sprites/confirm.png"], - "palettes": ["palettes/ui_main.pal"] - }, - "output": { - "format": "TILES/indexed_v1", - "codec": "RAW" - }, - "preload": { "enabled": true } - } - """); - Files.writeString(assetsRoot.resolve(".prometeu").resolve("index.json"), """ - { - "schema_version": 1, - "next_asset_id": 2, - "assets": [ - { - "asset_id": 1, - "root": "ui/atlas" - } - ] - } - """); - - final FileSystemAssetWorkspaceService service = new FileSystemAssetWorkspaceService(); - - final AssetWorkspaceAssetDetails details = service.loadAssetDetails( - project("Main", projectRoot), - new AssetWorkspaceSelectionKey.ManagedAsset(1)); - - assertEquals("ui_atlas", details.summary().assetName()); - assertEquals("TILES/indexed_v1", details.outputFormat()); - assertEquals("RAW", details.outputCodec()); - assertEquals(List.of(managedRoot.resolve("sprites/confirm.png").toAbsolutePath().normalize()), details.inputsByRole().get("sprites")); - assertEquals(List.of(managedRoot.resolve("palettes/ui_main.pal").toAbsolutePath().normalize()), details.inputsByRole().get("palettes")); - } - - private ProjectReference project(String name, Path root) { - return new ProjectReference(name, "1.0.0", "pbs", 1, root); - } -} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetCreationServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetCreationServiceTest.java deleted file mode 100644 index 4f103d8b..00000000 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetCreationServiceTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package p.studio.workspaces.assets; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import p.studio.projects.ProjectReference; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -final class PackerBackedAssetCreationServiceTest { - @TempDir - Path tempDir; - - @Test - void createWritesManifestAndRegistersManagedAsset() throws Exception { - final Path projectRoot = tempDir.resolve("main"); - Files.createDirectories(projectRoot.resolve("assets")); - final PackerBackedAssetCreationService service = new PackerBackedAssetCreationService(); - - final AssetCreationResult result = service.create(project(), new AssetCreationRequest( - "ui_atlas", - "ui/atlas", - "image_bank", - "TILES/indexed_v1", - "RAW", - true)); - - assertEquals(new AssetWorkspaceSelectionKey.ManagedAsset(1), result.selectionKey()); - assertTrue(Files.isDirectory(projectRoot.resolve("assets/ui/atlas"))); - final String manifest = Files.readString(projectRoot.resolve("assets/ui/atlas/asset.json")); - assertTrue(manifest.contains("\"name\" : \"ui_atlas\"")); - assertTrue(manifest.contains("\"type\" : \"image_bank\"")); - assertTrue(manifest.contains("\"format\" : \"TILES/indexed_v1\"")); - assertTrue(manifest.contains("\"codec\" : \"RAW\"")); - assertTrue(manifest.contains("\"inputs\" : { }")); - final String registry = Files.readString(projectRoot.resolve("assets/.prometeu/index.json")); - assertTrue(registry.contains("\"asset_id\" : 1")); - assertTrue(registry.contains("\"root\" : \"ui/atlas\"")); - } - - @Test - void createRejectsUntrustedRelativeRoot() throws Exception { - final Path projectRoot = tempDir.resolve("main"); - Files.createDirectories(projectRoot.resolve("assets")); - final PackerBackedAssetCreationService service = new PackerBackedAssetCreationService(); - - assertThrows(IllegalArgumentException.class, () -> service.create(project(), new AssetCreationRequest( - "ui_atlas", - "../escape", - "image_bank", - "TILES/indexed_v1", - "RAW", - true))); - } - - @Test - void createRejectsExistingAssetManifest() throws Exception { - final Path projectRoot = tempDir.resolve("main"); - final Path assetRoot = projectRoot.resolve("assets/ui/existing"); - Files.createDirectories(assetRoot); - Files.writeString(assetRoot.resolve("asset.json"), "{}"); - final PackerBackedAssetCreationService service = new PackerBackedAssetCreationService(); - - assertThrows(IllegalArgumentException.class, () -> service.create(project(), new AssetCreationRequest( - "ui_existing", - "ui/existing", - "image_bank", - "TILES/indexed_v1", - "RAW", - true))); - } - - @Test - void createRejectsUnsupportedTypeFormatCodecCombination() throws Exception { - final Path projectRoot = tempDir.resolve("main"); - Files.createDirectories(projectRoot.resolve("assets")); - final PackerBackedAssetCreationService service = new PackerBackedAssetCreationService(); - - assertThrows(IllegalArgumentException.class, () -> service.create(project(), new AssetCreationRequest( - "ui_atlas", - "ui/atlas", - "sound_bank", - "TILES/indexed_v1", - "RAW", - true))); - } - - private ProjectReference project() { - return new ProjectReference("Main", "1.0.0", "pbs", 1, tempDir.resolve("main")); - } -} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationServiceTest.java deleted file mode 100644 index 33d9cce8..00000000 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceMutationServiceTest.java +++ /dev/null @@ -1,176 +0,0 @@ -package p.studio.workspaces.assets; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import p.packer.declarations.PackerAssetDetailsService; -import p.packer.foundation.PackerWorkspaceFoundation; -import p.packer.mutations.PackerProjectWriteCoordinator; -import p.studio.events.StudioAssetsMutationAppliedEvent; -import p.studio.events.StudioAssetsMutationFailedEvent; -import p.studio.events.StudioAssetsMutationPreviewReadyEvent; -import p.studio.events.StudioEventBus; -import p.studio.events.StudioWorkspaceEventBus; -import p.studio.projects.ProjectReference; -import p.studio.workspaces.WorkspaceId; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -final class PackerBackedAssetWorkspaceMutationServiceTest { - @TempDir - Path tempDir; - - @Test - void previewMapsPackerImpactAndPublishesPreviewReadyEvent() throws Exception { - final Path projectRoot = createManagedAssetProject(); - final StudioEventBus globalBus = new StudioEventBus(); - final List previewEvents = new ArrayList<>(); - globalBus.subscribe(StudioAssetsMutationPreviewReadyEvent.class, previewEvents::add); - final PackerBackedAssetWorkspaceMutationService service = service(globalBus); - - final AssetWorkspaceMutationPreview preview = service.preview( - project("Main", projectRoot), - managedAsset(projectRoot), - AssetWorkspaceAction.RELOCATE, - projectRoot.resolve("assets/reorganized/atlas")); - - assertEquals(1, previewEvents.size()); - assertTrue(preview.canApply()); - assertNotNull(preview.targetAssetRoot()); - assertEquals(1, preview.changes().stream().filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.REGISTRY).count()); - assertEquals(1, preview.changes().stream().filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.WORKSPACE).count()); - } - - @Test - void applyPublishesAppliedEventFromPackerLifecycle() throws Exception { - final Path projectRoot = createManagedAssetProject(); - final StudioEventBus globalBus = new StudioEventBus(); - final List appliedEvents = new ArrayList<>(); - globalBus.subscribe(StudioAssetsMutationAppliedEvent.class, appliedEvents::add); - final PackerBackedAssetWorkspaceMutationService service = service(globalBus); - final Path customTarget = projectRoot.resolve("assets/reorganized/atlas"); - final AssetWorkspaceMutationPreview preview = service.preview( - project("Main", projectRoot), - managedAsset(projectRoot), - AssetWorkspaceAction.RELOCATE, - customTarget); - - service.apply(project("Main", projectRoot), preview); - - assertEquals(1, appliedEvents.size()); - assertEquals(AssetWorkspaceAction.RELOCATE, appliedEvents.getFirst().action()); - assertEquals(customTarget.toAbsolutePath().normalize(), preview.targetAssetRoot()); - assertTrue(Files.isDirectory(customTarget)); - } - - @Test - void applyFailurePublishesFailedEventFromPackerLifecycle() throws Exception { - final Path projectRoot = createManagedAssetProject(); - final StudioEventBus globalBus = new StudioEventBus(); - final List failedEvents = new ArrayList<>(); - globalBus.subscribe(StudioAssetsMutationFailedEvent.class, failedEvents::add); - final PackerBackedAssetWorkspaceMutationService service = service(globalBus); - final Path customTarget = projectRoot.resolve("assets/reorganized/atlas"); - final AssetWorkspaceMutationPreview preview = service.preview( - project("Main", projectRoot), - managedAsset(projectRoot), - AssetWorkspaceAction.RELOCATE, - customTarget); - - deleteRecursively(projectRoot.resolve("assets/ui/atlas")); - - assertThrows(RuntimeException.class, () -> service.apply(project("Main", projectRoot), preview)); - assertEquals(1, failedEvents.size()); - assertEquals(AssetWorkspaceAction.RELOCATE, failedEvents.getFirst().action()); - assertFalse(failedEvents.getFirst().message().isBlank()); - } - - @Test - void previewRelocateWithExistingDestinationReturnsBlockerWithoutCallingPacker() throws Exception { - final Path projectRoot = createManagedAssetProject(); - final Path existingTarget = projectRoot.resolve("assets/ui/existing"); - Files.createDirectories(existingTarget); - final StudioEventBus globalBus = new StudioEventBus(); - final List previewEvents = new ArrayList<>(); - globalBus.subscribe(StudioAssetsMutationPreviewReadyEvent.class, previewEvents::add); - final PackerBackedAssetWorkspaceMutationService service = service(globalBus); - - final AssetWorkspaceMutationPreview preview = service.preview( - project("Main", projectRoot), - managedAsset(projectRoot), - AssetWorkspaceAction.RELOCATE, - existingTarget); - - assertEquals(1, previewEvents.size()); - assertFalse(preview.canApply()); - assertTrue(preview.blockers().stream().anyMatch(message -> message.contains("already exists"))); - } - - private PackerBackedAssetWorkspaceMutationService service(StudioEventBus globalBus) { - return new PackerBackedAssetWorkspaceMutationService( - new StudioWorkspaceEventBus(WorkspaceId.ASSETS, globalBus), - new PackerWorkspaceFoundation(), - new PackerAssetDetailsService(), - new PackerProjectWriteCoordinator()); - } - - private Path createManagedAssetProject() throws Exception { - final Path projectRoot = tempDir.resolve("main"); - final Path assetRoot = projectRoot.resolve("assets/ui/atlas"); - Files.createDirectories(assetRoot); - Files.createDirectories(projectRoot.resolve("assets/.prometeu")); - Files.writeString(assetRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "name": "ui_atlas", - "type": "image_bank", - "preload": { "enabled": true } - } - """); - Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """ - { - "schema_version": 1, - "next_asset_id": 2, - "assets": [ - { - "asset_id": 1, - "asset_uuid": "uuid-1", - "root": "ui/atlas" - } - ] - } - """); - return projectRoot; - } - - private AssetWorkspaceAssetSummary managedAsset(Path projectRoot) { - return new AssetWorkspaceAssetSummary( - new AssetWorkspaceSelectionKey.ManagedAsset(1), - "ui_atlas", - AssetWorkspaceAssetState.REGISTERED, - AssetWorkspaceBuildParticipation.INCLUDED, - 1, - "image_bank", - projectRoot.resolve("assets/ui/atlas"), - true, - false); - } - - private ProjectReference project(String name, Path root) { - return new ProjectReference(name, "1.0.0", "pbs", 1, root); - } - - private void deleteRecursively(Path root) throws IOException { - try (var stream = Files.walk(root)) { - for (Path path : stream.sorted(Comparator.reverseOrder()).toList()) { - Files.deleteIfExists(path); - } - } - } -} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceServiceTest.java deleted file mode 100644 index 40391e79..00000000 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceServiceTest.java +++ /dev/null @@ -1,93 +0,0 @@ -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.*; - -final class PackerBackedAssetWorkspaceServiceTest { - @TempDir - Path tempDir; - - @Test - void loadsWorkspaceThroughPackerBackedSnapshot() throws Exception { - final Path projectRoot = tempDir.resolve("main"); - final Path managedRoot = projectRoot.resolve("assets/ui/atlas"); - final Path orphanRoot = projectRoot.resolve("assets/orphans/ui_sounds"); - Files.createDirectories(managedRoot); - Files.createDirectories(orphanRoot); - Files.createDirectories(projectRoot.resolve("assets/.prometeu")); - Files.writeString(managedRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "name": "ui_atlas", - "type": "image_bank", - "inputs": { "sprites": ["sprites/confirm.png"] }, - "output": { "format": "TILES/indexed_v1", "codec": "RAW" }, - "preload": { "enabled": true } - } - """); - Files.writeString(orphanRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "name": "ui_sounds", - "type": "sound_bank", - "inputs": { "sources": ["confirm.wav"] }, - "output": { "format": "SOUND/bank_v1", "codec": "RAW" }, - "preload": { "enabled": false } - } - """); - Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """ - { - "schema_version": 1, - "next_asset_id": 2, - "assets": [ - { - "asset_id": 1, - "asset_uuid": "uuid-1", - "root": "ui/atlas" - } - ] - } - """); - - final PackerBackedAssetWorkspaceService service = new PackerBackedAssetWorkspaceService(); - - final AssetWorkspaceSnapshot snapshot = service.loadWorkspace(project("Main", projectRoot)); - - assertEquals(2, snapshot.assets().size()); - assertTrue(snapshot.assets().stream().anyMatch(asset -> asset.state() == AssetWorkspaceAssetState.REGISTERED)); - assertTrue(snapshot.assets().stream().anyMatch(asset -> asset.state() == AssetWorkspaceAssetState.UNREGISTERED)); - } - - @Test - void mapsInvalidDetailsToStudioDiagnostics() throws Exception { - final Path projectRoot = tempDir.resolve("main"); - final Path invalidRoot = projectRoot.resolve("assets/bad"); - Files.createDirectories(invalidRoot); - Files.writeString(invalidRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "type": "image_bank" - } - """); - - final PackerBackedAssetWorkspaceService service = new PackerBackedAssetWorkspaceService(); - - final AssetWorkspaceAssetDetails details = service.loadAssetDetails( - project("Main", projectRoot), - new AssetWorkspaceSelectionKey.OrphanAsset(invalidRoot)); - - assertEquals("unknown", details.outputFormat()); - assertFalse(details.diagnostics().isEmpty()); - assertEquals(AssetWorkspaceDiagnosticSeverity.BLOCKER, details.diagnostics().getFirst().severity()); - } - - private ProjectReference project(String name, Path root) { - return new ProjectReference(name, "1.0.0", "pbs", 1, root); - } -} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerStudioIntegrationTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerStudioIntegrationTest.java deleted file mode 100644 index 77ba2b0e..00000000 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerStudioIntegrationTest.java +++ /dev/null @@ -1,133 +0,0 @@ -package p.studio.workspaces.assets; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import p.packer.building.FileSystemPackerBuildService; -import p.packer.api.PackerProjectContext; -import p.packer.api.building.PackerBuildRequest; -import p.packer.api.doctor.PackerDoctorMode; -import p.packer.api.doctor.PackerDoctorRequest; -import p.packer.declarations.PackerAssetDeclarationParser; -import p.packer.declarations.PackerAssetDetailsService; -import p.packer.doctor.FileSystemPackerDoctorService; -import p.packer.foundation.PackerWorkspaceFoundation; -import p.packer.mutations.PackerProjectWriteCoordinator; -import p.packer.workspace.FileSystemPackerWorkspaceService; -import p.studio.events.StudioAssetsMutationAppliedEvent; -import p.studio.events.StudioEventBus; -import p.studio.events.StudioPackerEventAdapter; -import p.studio.events.StudioPackerOperationEvent; -import p.studio.events.StudioWorkspaceEventBus; -import p.studio.projects.ProjectReference; -import p.studio.workspaces.WorkspaceId; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -final class PackerStudioIntegrationTest { - @TempDir - Path tempDir; - - @Test - void packerServicesAndStudioAdapterWorkTogetherEndToEnd() throws Exception { - final Path projectRoot = createProject(tempDir.resolve("main")); - final ProjectReference project = new ProjectReference("Main", "1.0.0", "pbs", 1, projectRoot); - final StudioEventBus globalBus = new StudioEventBus(); - final StudioWorkspaceEventBus workspaceBus = new StudioWorkspaceEventBus(WorkspaceId.ASSETS, globalBus); - final StudioPackerEventAdapter packerAdapter = new StudioPackerEventAdapter(workspaceBus, project); - final List packerEvents = new ArrayList<>(); - final List mutationEvents = new ArrayList<>(); - globalBus.subscribe(StudioPackerOperationEvent.class, packerEvents::add); - globalBus.subscribe(StudioAssetsMutationAppliedEvent.class, mutationEvents::add); - - final PackerBackedAssetWorkspaceService readService = new PackerBackedAssetWorkspaceService( - new FileSystemPackerWorkspaceService( - new PackerWorkspaceFoundation(), - new PackerAssetDeclarationParser(), - packerAdapter)); - final PackerBackedAssetWorkspaceMutationService mutationService = new PackerBackedAssetWorkspaceMutationService( - workspaceBus, - new PackerWorkspaceFoundation(), - new PackerAssetDetailsService(), - new PackerProjectWriteCoordinator()); - final FileSystemPackerDoctorService doctorService = new FileSystemPackerDoctorService( - new FileSystemPackerWorkspaceService( - new PackerWorkspaceFoundation(), - new PackerAssetDeclarationParser(), - packerAdapter), - new PackerAssetDetailsService(), - packerAdapter); - final FileSystemPackerBuildService buildService = new FileSystemPackerBuildService( - new p.packer.building.PackerBuildPlanner(), - packerAdapter); - - final AssetWorkspaceSnapshot snapshot = readService.loadWorkspace(project); - assertEquals(2, snapshot.assets().size()); - - final AssetWorkspaceAssetSummary orphan = snapshot.assets().stream() - .filter(asset -> asset.state() == AssetWorkspaceAssetState.UNREGISTERED) - .findFirst() - .orElseThrow(); - final AssetWorkspaceMutationPreview preview = mutationService.preview(project, orphan, AssetWorkspaceAction.REGISTER); - mutationService.apply(project, preview); - - final var doctor = doctorService.doctor(new PackerDoctorRequest( - new PackerProjectContext(project.name(), project.rootPath()), - PackerDoctorMode.EXPANDED_WORKSPACE, - true)); - final var build = buildService.build(new PackerBuildRequest(new PackerProjectContext(project.name(), project.rootPath()), false)); - - assertTrue(mutationEvents.stream().anyMatch(event -> event.action() == AssetWorkspaceAction.REGISTER)); - assertTrue(doctor.status() == p.packer.api.PackerOperationStatus.SUCCESS || doctor.status() == p.packer.api.PackerOperationStatus.PARTIAL); - assertTrue(Files.isRegularFile(build.assetsArchive())); - assertTrue(packerEvents.stream().anyMatch(event -> event.kind() == p.packer.api.events.PackerEventKind.BUILD_FINISHED)); - } - - private Path createProject(Path projectRoot) throws Exception { - final Path managedRoot = projectRoot.resolve("assets/ui/atlas"); - final Path orphanRoot = projectRoot.resolve("assets/orphans/ui_sounds"); - Files.createDirectories(managedRoot.resolve("sprites")); - Files.createDirectories(orphanRoot.resolve("sources")); - Files.createDirectories(projectRoot.resolve("assets/.prometeu")); - Files.writeString(managedRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "name": "ui_atlas", - "type": "image_bank", - "inputs": { "sprites": ["sprites/confirm.png"] }, - "output": { "format": "TILES/indexed_v1", "codec": "RAW" }, - "preload": { "enabled": true } - } - """); - Files.writeString(orphanRoot.resolve("asset.json"), """ - { - "schema_version": 1, - "name": "ui_sounds", - "type": "sound_bank", - "inputs": { "sources": ["sources/confirm.wav"] }, - "output": { "format": "SOUND/bank_v1", "codec": "RAW" }, - "preload": { "enabled": false } - } - """); - Files.writeString(managedRoot.resolve("sprites/confirm.png"), "png"); - Files.writeString(orphanRoot.resolve("sources/confirm.wav"), "wav"); - Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """ - { - "schema_version": 1, - "next_asset_id": 2, - "assets": [ - { - "asset_id": 1, - "asset_uuid": "uuid-1", - "root": "ui/atlas" - } - ] - } - """); - return projectRoot; - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9358a877..5e2c55c2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,7 +5,10 @@ plugins { rootProject.name = "prometeu-studio" include("prometeu-infra") -include("prometeu-packer") +include("prometeu-lsp:prometeu-lsp-api") +include("prometeu-lsp:prometeu-lsp-v1") +include("prometeu-packer:prometeu-packer-api") +include("prometeu-packer:prometeu-packer-v1") include("prometeu-compiler:frontends:prometeu-frontend-pbs") include("prometeu-compiler:prometeu-compiler-core") @@ -15,3 +18,5 @@ include("prometeu-compiler:prometeu-frontend-api") include("prometeu-compiler:prometeu-frontend-registry") include("prometeu-studio") + +include("prometeu-app") diff --git a/test-projects/main/.studio/activities.json b/test-projects/main/.studio/activities.json new file mode 100644 index 00000000..a3afd8f2 --- /dev/null +++ b/test-projects/main/.studio/activities.json @@ -0,0 +1,386 @@ +[ { + "source" : "Assets", + "message" : "8 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: test", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: one-more-atlas", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: ui_atlas", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: bla", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: one-more-atlas", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: Novo Asset", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: ui_atlas", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: Bigode", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "8 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: test", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: one-more-atlas", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: ui_atlas", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: bla", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: one-more-atlas", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: Novo Asset", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: ui_atlas", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Discovered asset: Bigode", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "6 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "6 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "6 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "6 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "6 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "6 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "6 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "6 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "6 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "6 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "6 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "6 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "5 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "5 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "5 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "4 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "4 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "4 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "4 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "4 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "4 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "4 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "4 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Studio", + "message" : "Project opened: main", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "4 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "4 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "4 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "4 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +}, { + "source" : "Assets", + "message" : "4 assets loaded", + "severity" : "SUCCESS", + "sticky" : false +}, { + "source" : "Assets", + "message" : "Asset scan started", + "severity" : "INFO", + "sticky" : false +} ] \ No newline at end of file diff --git a/test-projects/main/assets/.prometeu/index.json b/test-projects/main/assets/.prometeu/index.json index 1e7f9a81..9afa6971 100644 --- a/test-projects/main/assets/.prometeu/index.json +++ b/test-projects/main/assets/.prometeu/index.json @@ -1,6 +1,6 @@ { "schema_version" : 1, - "next_asset_id" : 9, + "next_asset_id" : 12, "assets" : [ { "asset_id" : 3, "asset_uuid" : "21953cb8-4101-4790-9e5e-d95f5fbc9b5a", @@ -15,6 +15,21 @@ "asset_id" : 8, "asset_uuid" : "9a7386e7-6f0e-4e4c-9919-0de71e0b7031", "root" : "sound", - "included_in_build" : false + "included_in_build" : true + }, { + "asset_id" : 9, + "asset_uuid" : "b23fd13b-9a52-4a76-9e8a-9286f59757b2", + "root" : "ui/test", + "included_in_build" : true + }, { + "asset_id" : 10, + "asset_uuid" : "c025c9bd-7fc0-4a9f-8f00-0fd673c2a9d3", + "root" : "recovered/new asset", + "included_in_build" : true + }, { + "asset_id" : 11, + "asset_uuid" : "64147d33-e8bf-4272-bb5c-b4c07c0276b3", + "root" : "bigode", + "included_in_build" : true } ] } \ No newline at end of file diff --git a/test-projects/main/assets/bigode/asset.json b/test-projects/main/assets/bigode/asset.json new file mode 100644 index 00000000..300684b2 --- /dev/null +++ b/test-projects/main/assets/bigode/asset.json @@ -0,0 +1,14 @@ +{ + "schema_version" : 1, + "asset_uuid" : "64147d33-e8bf-4272-bb5c-b4c07c0276b3", + "name" : "Bigode", + "type" : "image_bank", + "inputs" : { }, + "output" : { + "format" : "TILES/indexed_v1", + "codec" : "NONE" + }, + "preload" : { + "enabled" : true + } +} diff --git a/test-projects/main/assets/recovered/atlas2/asset.json b/test-projects/main/assets/recovered/atlas2/asset.json new file mode 100644 index 00000000..5320540b --- /dev/null +++ b/test-projects/main/assets/recovered/atlas2/asset.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "asset_uuid": "b15b319f-5cab-4254-93ea-d83f4742d204", + "name": "ui_atlas", + "type": "image_bank", + "inputs": { "sprites": ["sprites/confirm.png"] }, + "output": { "format": "TILES/indexed_v1", "codec": "NONE" }, + "preload": { "enabled": true } +} diff --git a/test-projects/main/assets/recovered/atlas2/sprites/confirm.png b/test-projects/main/assets/recovered/atlas2/sprites/confirm.png new file mode 100644 index 00000000..1ee41b70 Binary files /dev/null and b/test-projects/main/assets/recovered/atlas2/sprites/confirm.png differ diff --git a/test-projects/main/assets/recovered/new asset/asset.json b/test-projects/main/assets/recovered/new asset/asset.json new file mode 100644 index 00000000..812ac88f --- /dev/null +++ b/test-projects/main/assets/recovered/new asset/asset.json @@ -0,0 +1,14 @@ +{ + "schema_version" : 1, + "asset_uuid" : "c025c9bd-7fc0-4a9f-8f00-0fd673c2a9d3", + "name" : "Novo Asset", + "type" : "image_bank", + "inputs" : { }, + "output" : { + "format" : "TILES/indexed_v1", + "codec" : "NONE" + }, + "preload" : { + "enabled" : false + } +} diff --git a/test-projects/main/assets/recovered/one-more-atlas/asset.json b/test-projects/main/assets/recovered/one-more-atlas/asset.json new file mode 100644 index 00000000..421e763b --- /dev/null +++ b/test-projects/main/assets/recovered/one-more-atlas/asset.json @@ -0,0 +1,14 @@ +{ + "schema_version" : 1, + "asset_uuid" : "4d9847b0-5a23-421f-8b78-bf3909ca2281", + "name" : "one-more-atlas", + "type" : "image_bank", + "inputs" : { }, + "output" : { + "format" : "TILES/indexed_v1", + "codec" : "NONE" + }, + "preload" : { + "enabled" : true + } +} diff --git a/test-projects/main/assets/sound/asset.json b/test-projects/main/assets/sound/asset.json index ba7990ac..3f5e2332 100644 --- a/test-projects/main/assets/sound/asset.json +++ b/test-projects/main/assets/sound/asset.json @@ -1,13 +1,14 @@ { "schema_version" : 1, + "asset_uuid" : "9a7386e7-6f0e-4e4c-9919-0de71e0b7031", "name" : "bla", "type" : "sound_bank", "inputs" : { }, "output" : { "format" : "AUDIO/pcm_v1", - "codec" : "RAW" + "codec" : "NONE" }, "preload" : { "enabled" : true } -} \ No newline at end of file +} diff --git a/test-projects/main/assets/ui/atlas2/asset.json b/test-projects/main/assets/ui/atlas2/asset.json index e500ff6e..c2b7bf1c 100644 --- a/test-projects/main/assets/ui/atlas2/asset.json +++ b/test-projects/main/assets/ui/atlas2/asset.json @@ -1,8 +1,9 @@ { "schema_version": 1, + "asset_uuid": "21953cb8-4101-4790-9e5e-d95f5fbc9b5a", "name": "ui_atlas", "type": "image_bank", "inputs": { "sprites": ["sprites/confirm.png"] }, - "output": { "format": "TILES/indexed_v1", "codec": "RAW" }, + "output": { "format": "TILES/indexed_v1", "codec": "NONE" }, "preload": { "enabled": true } } diff --git a/test-projects/main/assets/ui/one-more-atlas/asset.json b/test-projects/main/assets/ui/one-more-atlas/asset.json index 5a0b7d8d..581a8903 100644 --- a/test-projects/main/assets/ui/one-more-atlas/asset.json +++ b/test-projects/main/assets/ui/one-more-atlas/asset.json @@ -1,13 +1,14 @@ { "schema_version" : 1, + "asset_uuid" : "62a81570-8f47-4612-9288-6060e6c9a2e2", "name" : "one-more-atlas", "type" : "image_bank", "inputs" : { }, "output" : { "format" : "TILES/indexed_v1", - "codec" : "RAW" + "codec" : "NONE" }, "preload" : { "enabled" : true } -} \ No newline at end of file +} diff --git a/test-projects/main/assets/ui/test/asset.json b/test-projects/main/assets/ui/test/asset.json new file mode 100644 index 00000000..44b1c9f8 --- /dev/null +++ b/test-projects/main/assets/ui/test/asset.json @@ -0,0 +1,14 @@ +{ + "schema_version" : 1, + "asset_uuid" : "b23fd13b-9a52-4a76-9e8a-9286f59757b2", + "name" : "test", + "type" : "palette_bank", + "inputs" : { }, + "output" : { + "format" : "PALETTE/indexed_v1", + "codec" : "NONE" + }, + "preload" : { + "enabled" : false + } +}