diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceRefreshFailedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceRefreshFailedEvent.java new file mode 100644 index 00000000..05f62d6c --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceRefreshFailedEvent.java @@ -0,0 +1,12 @@ +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 new file mode 100644 index 00000000..d3031957 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceRefreshStartedEvent.java @@ -0,0 +1,11 @@ +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 new file mode 100644 index 00000000..38863711 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceRefreshedEvent.java @@ -0,0 +1,14 @@ +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 new file mode 100644 index 00000000..77965783 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsWorkspaceSelectionChangedEvent.java @@ -0,0 +1,15 @@ +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/utilities/i18n/I18n.java b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java index 936af89b..65dddfa3 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 @@ -78,6 +78,20 @@ public enum I18n { WORKSPACE_SHIPPER_BUTTON_CLEAR("workspace.shipper.button.clear"), WORKSPACE_ASSETS("workspace.assets"), + ASSETS_NAVIGATOR_TITLE("assets.navigator.title"), + ASSETS_DETAILS_TITLE("assets.details.title"), + ASSETS_STATE_LOADING("assets.state.loading"), + ASSETS_STATE_EMPTY("assets.state.empty"), + ASSETS_STATE_READY("assets.state.ready"), + ASSETS_STATE_ERROR("assets.state.error"), + ASSETS_SUMMARY_LOADING("assets.summary.loading"), + ASSETS_SUMMARY_EMPTY("assets.summary.empty"), + ASSETS_SUMMARY_READY("assets.summary.ready"), + ASSETS_SUMMARY_ERROR("assets.summary.error"), + ASSETS_DETAILS_LOADING("assets.details.loading"), + ASSETS_DETAILS_EMPTY("assets.details.empty"), + ASSETS_DETAILS_READY("assets.details.ready"), + ASSETS_DETAILS_NO_SELECTION("assets.details.noSelection"), WORKSPACE_DEBUG("workspace.debug"), 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 84307d6b..72cb4f5a 100644 --- a/prometeu-studio/src/main/java/p/studio/window/MainView.java +++ b/prometeu-studio/src/main/java/p/studio/window/MainView.java @@ -9,6 +9,7 @@ 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.editor.EditorWorkspace; @@ -25,7 +26,7 @@ public final class MainView extends BorderPane { setTop(new StudioShellTopBarControl(menuBar)); host.register(new EditorWorkspace()); - host.register(new PlaceholderWorkspace(WorkspaceId.ASSETS, I18n.WORKSPACE_ASSETS, "Assets")); + host.register(new AssetWorkspace(projectReference)); host.register(new BuilderWorkspace(projectReference)); host.register(new PlaceholderWorkspace(WorkspaceId.DEBUG, I18n.WORKSPACE_DEBUG, "Debug")); 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 new file mode 100644 index 00000000..12dac284 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java @@ -0,0 +1,158 @@ +package p.studio.workspaces.assets; + +import javafx.application.Platform; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.SplitPane; +import javafx.scene.layout.BorderPane; +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 java.util.concurrent.CompletableFuture; + +public final class AssetWorkspace implements Workspace { + private final BorderPane root = new BorderPane(); + private final ProjectReference projectReference; + private final AssetWorkspaceService assetWorkspaceService; + private final StudioWorkspaceEventBus workspaceBus; + private final Label navigatorStateLabel = new Label(); + private final Label detailStateLabel = new Label(); + private final Label workspaceSummaryLabel = new Label(); + + private volatile AssetWorkspaceState state = AssetWorkspaceState.loading(null); + + public AssetWorkspace(ProjectReference projectReference) { + this(projectReference, new FileSystemAssetWorkspaceService()); + } + + public AssetWorkspace(ProjectReference projectReference, AssetWorkspaceService assetWorkspaceService) { + this.projectReference = projectReference; + this.assetWorkspaceService = assetWorkspaceService; + this.workspaceBus = new StudioWorkspaceEventBus(WorkspaceId.ASSETS, Container.events()); + + root.getStyleClass().add("assets-workspace"); + root.setCenter(buildLayout()); + renderState(); + } + + @Override + public WorkspaceId id() { + return WorkspaceId.ASSETS; + } + + @Override + public I18n title() { + return I18n.WORKSPACE_ASSETS; + } + + @Override + public Node root() { + return root; + } + + @Override + public void onShow() { + refresh(); + } + + AssetWorkspaceState state() { + return state; + } + + private SplitPane buildLayout() { + final VBox navigatorPane = new VBox(8); + final Label navigatorTitle = new Label(); + navigatorTitle.textProperty().bind(Container.i18n().bind(I18n.ASSETS_NAVIGATOR_TITLE)); + navigatorTitle.getStyleClass().add("assets-workspace-pane-title"); + navigatorStateLabel.getStyleClass().add("assets-workspace-pane-body"); + navigatorPane.getStyleClass().add("assets-workspace-pane"); + navigatorPane.getChildren().addAll(navigatorTitle, navigatorStateLabel); + + final VBox detailsPane = new VBox(8); + 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"); + detailStateLabel.getStyleClass().add("assets-workspace-pane-body"); + detailsPane.getStyleClass().add("assets-workspace-pane"); + detailsPane.getChildren().addAll(detailsTitle, workspaceSummaryLabel, detailStateLabel); + VBox.setVgrow(detailStateLabel, Priority.ALWAYS); + + final SplitPane splitPane = new SplitPane(navigatorPane, detailsPane); + splitPane.setDividerPositions(0.34); + splitPane.getStyleClass().add("assets-workspace-split"); + return splitPane; + } + + private void refresh() { + state = AssetWorkspaceState.loading(state); + renderState(); + workspaceBus.publish(new StudioAssetsWorkspaceRefreshStartedEvent(projectReference)); + + CompletableFuture + .supplyAsync(() -> assetWorkspaceService.loadWorkspace(projectReference)) + .whenComplete((snapshot, throwable) -> Platform.runLater(() -> { + if (throwable != null) { + state = AssetWorkspaceState.error(state, rootCauseMessage(throwable)); + renderState(); + workspaceBus.publish(new StudioAssetsWorkspaceRefreshFailedEvent(projectReference, state.errorMessage())); + return; + } + + state = AssetWorkspaceState.ready(snapshot.assets(), state.selectedKey()); + renderState(); + workspaceBus.publish(new StudioAssetsWorkspaceRefreshedEvent(projectReference, state.assets().size())); + state.selectedAsset() + .ifPresent(asset -> workspaceBus.publish(new StudioAssetsWorkspaceSelectionChangedEvent(projectReference, asset.selectionKey()))); + })); + } + + private void renderState() { + switch (state.status()) { + case LOADING -> { + navigatorStateLabel.setText(Container.i18n().text(I18n.ASSETS_STATE_LOADING)); + workspaceSummaryLabel.setText(Container.i18n().text(I18n.ASSETS_SUMMARY_LOADING)); + detailStateLabel.setText(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING)); + } + case EMPTY -> { + navigatorStateLabel.setText(Container.i18n().text(I18n.ASSETS_STATE_EMPTY)); + workspaceSummaryLabel.setText(Container.i18n().text(I18n.ASSETS_SUMMARY_EMPTY)); + detailStateLabel.setText(Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY)); + } + case ERROR -> { + navigatorStateLabel.setText(Container.i18n().text(I18n.ASSETS_STATE_ERROR) + "\n\n" + state.errorMessage()); + workspaceSummaryLabel.setText(Container.i18n().text(I18n.ASSETS_SUMMARY_ERROR)); + detailStateLabel.setText(state.errorMessage()); + } + case READY -> { + final int assetCount = state.assets().size(); + navigatorStateLabel.setText(Container.i18n().format(I18n.ASSETS_STATE_READY, assetCount)); + workspaceSummaryLabel.setText(Container.i18n().format(I18n.ASSETS_SUMMARY_READY, assetCount)); + final String selectedDescription = state.selectedAsset() + .map(asset -> Container.i18n().format( + I18n.ASSETS_DETAILS_READY, + asset.assetName(), + asset.state().name().toLowerCase(), + asset.assetRoot())) + .orElseGet(() -> Container.i18n().text(I18n.ASSETS_DETAILS_NO_SELECTION)); + detailStateLabel.setText(selectedDescription); + } + } + } + + 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(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetState.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetState.java new file mode 100644 index 00000000..7f31d269 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetState.java @@ -0,0 +1,6 @@ +package p.studio.workspaces.assets; + +public enum AssetWorkspaceAssetState { + MANAGED, + ORPHAN +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetSummary.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetSummary.java new file mode 100644 index 00000000..58fc590f --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetSummary.java @@ -0,0 +1,29 @@ +package p.studio.workspaces.assets; + +import java.nio.file.Path; +import java.util.Objects; + +public record AssetWorkspaceAssetSummary( + AssetWorkspaceSelectionKey selectionKey, + String assetName, + AssetWorkspaceAssetState state, + Integer assetId, + String assetFamily, + Path assetRoot, + boolean preload, + boolean hasDiagnostics) { + + public AssetWorkspaceAssetSummary { + Objects.requireNonNull(selectionKey, "selectionKey"); + assetName = Objects.requireNonNull(assetName, "assetName").trim(); + Objects.requireNonNull(state, "state"); + assetFamily = Objects.requireNonNullElse(assetFamily, "unknown").trim(); + assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); + if (assetName.isBlank()) { + throw new IllegalArgumentException("assetName must not be blank"); + } + if (state == AssetWorkspaceAssetState.MANAGED && assetId == null) { + throw new IllegalArgumentException("managed asset must expose assetId"); + } + } +} 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 new file mode 100644 index 00000000..f6817bdf --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceSelectionKey.java @@ -0,0 +1,32 @@ +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 new file mode 100644 index 00000000..1cae10bf --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceService.java @@ -0,0 +1,7 @@ +package p.studio.workspaces.assets; + +import p.studio.projects.ProjectReference; + +public interface AssetWorkspaceService { + AssetWorkspaceSnapshot loadWorkspace(ProjectReference projectReference); +} 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 new file mode 100644 index 00000000..65a39e72 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceSnapshot.java @@ -0,0 +1,10 @@ +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 new file mode 100644 index 00000000..c5dfd06a --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceState.java @@ -0,0 +1,78 @@ +package p.studio.workspaces.assets; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public record AssetWorkspaceState( + AssetWorkspaceStatus status, + List assets, + AssetWorkspaceSelectionKey selectedKey, + String errorMessage) { + + public AssetWorkspaceState { + Objects.requireNonNull(status, "status"); + assets = List.copyOf(Objects.requireNonNull(assets, "assets")); + } + + public static AssetWorkspaceState loading(AssetWorkspaceState previous) { + if (previous == null) { + return new AssetWorkspaceState(AssetWorkspaceStatus.LOADING, List.of(), null, null); + } + return new AssetWorkspaceState(AssetWorkspaceStatus.LOADING, previous.assets(), previous.selectedKey(), null); + } + + public static AssetWorkspaceState ready(List assets, AssetWorkspaceSelectionKey preferredSelectionKey) { + final List normalizedAssets = normalizeAssets(assets); + final AssetWorkspaceSelectionKey resolvedSelection = reconcileSelection(normalizedAssets, preferredSelectionKey); + final AssetWorkspaceStatus status = normalizedAssets.isEmpty() ? AssetWorkspaceStatus.EMPTY : AssetWorkspaceStatus.READY; + return new AssetWorkspaceState(status, normalizedAssets, resolvedSelection, null); + } + + public static AssetWorkspaceState error(AssetWorkspaceState previous, String errorMessage) { + return new AssetWorkspaceState( + AssetWorkspaceStatus.ERROR, + previous == null ? List.of() : previous.assets(), + previous == null ? null : previous.selectedKey(), + Objects.requireNonNullElse(errorMessage, "Unknown asset workspace error")); + } + + public Optional selectedAsset() { + if (selectedKey == null) { + return Optional.empty(); + } + return assets.stream() + .filter(asset -> asset.selectionKey().equals(selectedKey)) + .findFirst(); + } + + public AssetWorkspaceState withSelection(AssetWorkspaceSelectionKey nextSelectionKey) { + final AssetWorkspaceSelectionKey resolvedSelection = reconcileSelection(assets, nextSelectionKey); + return new AssetWorkspaceState(status, assets, resolvedSelection, errorMessage); + } + + static AssetWorkspaceSelectionKey reconcileSelection( + List assets, + AssetWorkspaceSelectionKey preferredSelectionKey) { + if (assets.isEmpty()) { + return null; + } + if (preferredSelectionKey != null) { + for (AssetWorkspaceAssetSummary asset : assets) { + if (asset.selectionKey().equals(preferredSelectionKey)) { + return preferredSelectionKey; + } + } + } + return assets.getFirst().selectionKey(); + } + + private static List normalizeAssets(List assets) { + return List.copyOf(Objects.requireNonNull(assets, "assets").stream() + .sorted(Comparator + .comparing((AssetWorkspaceAssetSummary asset) -> asset.assetRoot().toString(), String.CASE_INSENSITIVE_ORDER) + .thenComparing(AssetWorkspaceAssetSummary::assetName, String.CASE_INSENSITIVE_ORDER)) + .toList()); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceStatus.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceStatus.java new file mode 100644 index 00000000..d7f3733b --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceStatus.java @@ -0,0 +1,8 @@ +package p.studio.workspaces.assets; + +public enum AssetWorkspaceStatus { + LOADING, + READY, + EMPTY, + ERROR +} 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 new file mode 100644 index 00000000..fc7891e5 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceService.java @@ -0,0 +1,126 @@ +package p.studio.workspaces.assets; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +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); + } + } + + private AssetWorkspaceAssetSummary buildAssetSummary(Path assetManifestPath, Map registryByRoot) { + final Path assetRoot = assetManifestPath.getParent().toAbsolutePath().normalize(); + try { + final AssetManifest manifest = MAPPER.readValue(assetManifestPath.toFile(), AssetManifest.class); + final Integer assetId = registryByRoot.get(assetRoot); + final AssetWorkspaceAssetState state = assetId == null + ? AssetWorkspaceAssetState.ORPHAN + : AssetWorkspaceAssetState.MANAGED; + 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, + assetId, + assetFamily, + assetRoot, + preload, + false); + } catch (IOException ioException) { + final AssetWorkspaceSelectionKey selectionKey = new AssetWorkspaceSelectionKey.OrphanAsset(assetRoot); + return new AssetWorkspaceAssetSummary( + selectionKey, + assetRoot.getFileName().toString(), + AssetWorkspaceAssetState.ORPHAN, + 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.assetId()); + } + return Map.copyOf(registryByRoot); + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + } + + @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) { + private RegistryEntry( + @com.fasterxml.jackson.annotation.JsonProperty("asset_id") Integer assetId, + @com.fasterxml.jackson.annotation.JsonProperty("root") String root) { + this.assetId = assetId; + this.root = root; + } + } +} diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index 8cf69c56..451cf58e 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -68,4 +68,18 @@ workspace.shipper.button.run=Build workspace.shipper.button.clear=Clear workspace.assets=Assets +assets.navigator.title=Asset Navigator +assets.details.title=Selected Asset +assets.state.loading=Loading assets... +assets.state.empty=No managed or orphan assets were found in this project. +assets.state.ready={0} assets loaded. +assets.state.error=Asset workspace failed to load. +assets.summary.loading=Hydrating asset workspace state... +assets.summary.empty=No assets are currently available. +assets.summary.ready=Navigator ready with {0} assets. +assets.summary.error=Asset workspace unavailable. +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. workspace.debug=Debug diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index 18deede6..c3a0b9f7 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -113,6 +113,36 @@ -fx-padding: 16; } +.assets-workspace { + -fx-background-color: #17191d; +} + +.assets-workspace-split { + -fx-background-color: transparent; +} + +.assets-workspace-pane { + -fx-padding: 18; + -fx-spacing: 10; + -fx-background-color: #1b1f25; +} + +.assets-workspace-pane-title { + -fx-text-fill: #f3f7fb; + -fx-font-size: 15px; + -fx-font-weight: bold; +} + +.assets-workspace-summary { + -fx-text-fill: #9ecbff; + -fx-font-size: 12px; +} + +.assets-workspace-pane-body { + -fx-text-fill: #d3dbe5; + -fx-font-size: 12px; +} + .studio-project-launcher { -fx-background-color: linear-gradient(to bottom, #20242c, #14181d); } 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 new file mode 100644 index 00000000..cbd4f3ad --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceStateTest.java @@ -0,0 +1,84 @@ +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 preservesManagedSelectionByAssetIdAcrossRefresh() { + 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 preservesOrphanSelectionByAssetRootAcrossRefresh() { + final Path orphanRoot = Path.of("/tmp/assets/orphan_bank"); + final AssetWorkspaceSelectionKey.OrphanAsset selected = new AssetWorkspaceSelectionKey.OrphanAsset(orphanRoot); + final List assets = List.of( + orphanAsset("orphan_bank", orphanRoot), + orphanAsset("other_bank", Path.of("/tmp/assets/other_bank"))); + + final AssetWorkspaceState state = AssetWorkspaceState.ready(assets, selected); + + assertEquals(selected, state.selectedKey()); + assertEquals(orphanRoot.toAbsolutePath().normalize(), state.selectedAsset().orElseThrow().assetRoot()); + } + + @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.MANAGED, + assetId, + "image_bank", + root, + false, + false); + } + + private AssetWorkspaceAssetSummary orphanAsset(String name, Path root) { + return new AssetWorkspaceAssetSummary( + new AssetWorkspaceSelectionKey.OrphanAsset(root), + name, + AssetWorkspaceAssetState.ORPHAN, + null, + "image_bank", + root, + false, + false); + } +} 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 new file mode 100644 index 00000000..a9497411 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceServiceTest.java @@ -0,0 +1,88 @@ +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.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 marksRegistryRootsAsManagedAndUnregisteredAnchorsAsOrphan() 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 managed = snapshot.assets().stream() + .filter(asset -> asset.assetName().equals("ui_atlas")) + .findFirst() + .orElseThrow(); + final AssetWorkspaceAssetSummary orphan = snapshot.assets().stream() + .filter(asset -> asset.assetName().equals("ui_sounds")) + .findFirst() + .orElseThrow(); + + assertEquals(AssetWorkspaceAssetState.MANAGED, managed.state()); + assertEquals(1, managed.assetId()); + assertTrue(managed.preload()); + + assertEquals(AssetWorkspaceAssetState.ORPHAN, orphan.state()); + assertEquals(null, orphan.assetId()); + } + + private ProjectReference project(String name, Path root) { + return new ProjectReference(name, "1.0.0", "pbs", 1, root); + } +}