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 380f151f..61a5b56d 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 @@ -94,6 +94,36 @@ public enum I18n { ASSETS_BADGE_ORPHAN("assets.badge.orphan"), ASSETS_BADGE_PRELOAD("assets.badge.preload"), ASSETS_BADGE_DIAGNOSTICS("assets.badge.diagnostics"), + ASSETS_SECTION_SUMMARY("assets.section.summary"), + ASSETS_SECTION_RUNTIME_CONTRACT("assets.section.runtimeContract"), + ASSETS_SECTION_INPUTS_PREVIEW("assets.section.inputsPreview"), + ASSETS_SECTION_DIAGNOSTICS("assets.section.diagnostics"), + ASSETS_SECTION_ACTIONS("assets.section.actions"), + ASSETS_ACTIONS_PRIMARY("assets.actions.primary"), + ASSETS_ACTIONS_SENSITIVE("assets.actions.sensitive"), + ASSETS_ACTION_DOCTOR("assets.action.doctor"), + ASSETS_ACTION_BUILD("assets.action.build"), + ASSETS_ACTION_ADOPT("assets.action.adopt"), + ASSETS_ACTION_REGISTER("assets.action.register"), + ASSETS_ACTION_FORGET("assets.action.forget"), + ASSETS_ACTION_REMOVE("assets.action.remove"), + ASSETS_LABEL_NAME("assets.label.name"), + ASSETS_LABEL_STATE("assets.label.state"), + ASSETS_LABEL_ASSET_ID("assets.label.assetId"), + ASSETS_LABEL_TYPE("assets.label.type"), + ASSETS_LABEL_LOCATION("assets.label.location"), + ASSETS_LABEL_FORMAT("assets.label.format"), + ASSETS_LABEL_CODEC("assets.label.codec"), + ASSETS_LABEL_PRELOAD("assets.label.preload"), + ASSETS_VALUE_YES("assets.value.yes"), + ASSETS_VALUE_NO("assets.value.no"), + ASSETS_INPUTS_EMPTY("assets.inputs.empty"), + ASSETS_DIAGNOSTICS_EMPTY("assets.diagnostics.empty"), + ASSETS_PREVIEW_EMPTY("assets.preview.empty"), + ASSETS_PREVIEW_TEXT_ERROR("assets.preview.textError"), + ASSETS_PREVIEW_IMAGE_ERROR("assets.preview.imageError"), + ASSETS_PREVIEW_AUDIO_PLACEHOLDER("assets.preview.audioPlaceholder"), + ASSETS_PREVIEW_GENERIC_PLACEHOLDER("assets.preview.genericPlaceholder"), ASSETS_SUMMARY_LOADING("assets.summary.loading"), ASSETS_SUMMARY_EMPTY("assets.summary.empty"), ASSETS_SUMMARY_READY("assets.summary.ready"), 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 d1d0cea7..ace1d64e 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 @@ -2,8 +2,12 @@ package p.studio.workspaces.assets; 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.studio.Container; import p.studio.events.*; @@ -12,36 +16,47 @@ import p.studio.utilities.i18n.I18n; import p.studio.workspaces.Workspace; import p.studio.workspaces.WorkspaceId; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; -import java.util.concurrent.CompletableFuture; import java.util.EnumMap; import java.util.EnumSet; +import java.util.List; import java.util.Map; +import java.util.Objects; +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 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 Label detailStateLabel = new Label(); + + private final VBox detailsContent = new VBox(12); private final Label workspaceSummaryLabel = new Label(); + private final Map filterButtons = new EnumMap<>(AssetNavigatorFilter.class); private final EnumSet activeFilters = EnumSet.noneOf(AssetNavigatorFilter.class); - private String searchQuery = ""; private volatile AssetWorkspaceState state = AssetWorkspaceState.loading(null); + private volatile AssetWorkspaceDetailsStatus detailsStatus = AssetWorkspaceDetailsStatus.EMPTY; + private volatile AssetWorkspaceAssetDetails selectedAssetDetails; + private volatile String detailsErrorMessage; + private volatile Path selectedPreviewInput; + private String searchQuery = ""; public AssetWorkspace(ProjectReference projectReference) { this(projectReference, new FileSystemAssetWorkspaceService()); } public AssetWorkspace(ProjectReference projectReference, AssetWorkspaceService assetWorkspaceService) { - this.projectReference = projectReference; - this.assetWorkspaceService = assetWorkspaceService; + this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); + this.assetWorkspaceService = Objects.requireNonNull(assetWorkspaceService, "assetWorkspaceService"); this.workspaceBus = new StudioWorkspaceEventBus(WorkspaceId.ASSETS, Container.events()); root.getStyleClass().add("assets-workspace"); @@ -75,20 +90,22 @@ public final class AssetWorkspace implements Workspace { private SplitPane buildLayout() { final VBox navigatorPane = new VBox(8); + navigatorPane.getStyleClass().add("assets-workspace-pane"); 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 previous = oldValue == null ? "" : oldValue; final String current = newValue == null ? "" : newValue; - if (previous.equals(current)) { - return; + if (!previous.equals(current)) { + searchQuery = current; + renderState(); } - searchQuery = current; - renderState(); }); + configureFilterBar(); navigatorStateLabel.getStyleClass().add("assets-workspace-pane-body"); navigatorContent.getStyleClass().add("assets-workspace-navigator-content"); @@ -96,19 +113,22 @@ public final class AssetWorkspace implements Workspace { navigatorScroll.setFitToWidth(true); navigatorScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); navigatorScroll.getStyleClass().add("assets-workspace-navigator-scroll"); - navigatorPane.getStyleClass().add("assets-workspace-pane"); navigatorPane.getChildren().addAll(navigatorTitle, searchField, filterBar, navigatorStateLabel, navigatorScroll); VBox.setVgrow(navigatorScroll, Priority.ALWAYS); - final VBox detailsPane = new VBox(8); + final VBox detailsPane = new VBox(10); + detailsPane.getStyleClass().add("assets-workspace-pane"); 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); + detailsContent.getStyleClass().add("assets-workspace-details-content"); + final ScrollPane detailsScroll = new ScrollPane(detailsContent); + detailsScroll.setFitToWidth(true); + detailsScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + detailsScroll.getStyleClass().add("assets-workspace-details-scroll"); + detailsPane.getChildren().addAll(detailsTitle, workspaceSummaryLabel, detailsScroll); + VBox.setVgrow(detailsScroll, Priority.ALWAYS); final SplitPane splitPane = new SplitPane(navigatorPane, detailsPane); splitPane.setDividerPositions(0.34); @@ -145,6 +165,10 @@ public final class AssetWorkspace implements Workspace { private void refresh() { state = AssetWorkspaceState.loading(state); + detailsStatus = AssetWorkspaceDetailsStatus.EMPTY; + selectedAssetDetails = null; + detailsErrorMessage = null; + selectedPreviewInput = null; renderState(); workspaceBus.publish(new StudioAssetsWorkspaceRefreshStartedEvent(projectReference)); @@ -153,6 +177,8 @@ public final class AssetWorkspace implements Workspace { .whenComplete((snapshot, throwable) -> Platform.runLater(() -> { if (throwable != null) { state = AssetWorkspaceState.error(state, rootCauseMessage(throwable)); + detailsStatus = AssetWorkspaceDetailsStatus.ERROR; + detailsErrorMessage = state.errorMessage(); renderState(); workspaceBus.publish(new StudioAssetsWorkspaceRefreshFailedEvent(projectReference, state.errorMessage())); return; @@ -161,33 +187,61 @@ public final class AssetWorkspace implements Workspace { 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()))); + state.selectedAsset().ifPresent(asset -> { + workspaceBus.publish(new StudioAssetsWorkspaceSelectionChangedEvent(projectReference, asset.selectionKey())); + loadSelectedAssetDetails(asset.selectionKey()); + }); + })); + } + + private void loadSelectedAssetDetails(AssetWorkspaceSelectionKey selectionKey) { + detailsStatus = AssetWorkspaceDetailsStatus.LOADING; + selectedAssetDetails = null; + detailsErrorMessage = null; + selectedPreviewInput = null; + renderState(); + + CompletableFuture + .supplyAsync(() -> assetWorkspaceService.loadAssetDetails(projectReference, selectionKey)) + .whenComplete((details, throwable) -> Platform.runLater(() -> { + if (!selectionKey.equals(state.selectedKey())) { + return; + } + if (throwable != null) { + detailsStatus = AssetWorkspaceDetailsStatus.ERROR; + detailsErrorMessage = rootCauseMessage(throwable); + selectedAssetDetails = null; + renderState(); + return; + } + + selectedAssetDetails = details; + detailsStatus = AssetWorkspaceDetailsStatus.READY; + selectedPreviewInput = firstPreviewInput(details); + renderState(); })); } private void renderState() { + renderNavigator(); + renderDetails(); + } + + private void renderNavigator() { switch (state.status()) { case LOADING -> { navigatorStateLabel.setText(Container.i18n().text(I18n.ASSETS_STATE_LOADING)); navigatorContent.getChildren().setAll(createNavigatorMessage(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)); navigatorContent.getChildren().setAll(createNavigatorMessage(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()); navigatorContent.getChildren().setAll(createNavigatorMessage(state.errorMessage())); - workspaceSummaryLabel.setText(Container.i18n().text(I18n.ASSETS_SUMMARY_ERROR)); - detailStateLabel.setText(state.errorMessage()); } case READY -> { - final int assetCount = state.assets().size(); final AssetNavigatorProjection projection = AssetNavigatorProjectionBuilder.build( state.assets(), assetsRoot(), @@ -197,22 +251,297 @@ public final class AssetWorkspace implements Workspace { navigatorStateLabel.setText(Container.i18n().text(I18n.ASSETS_STATE_NO_RESULTS)); navigatorContent.getChildren().setAll(createNavigatorMessage(Container.i18n().text(I18n.ASSETS_STATE_NO_RESULTS))); } else { - navigatorStateLabel.setText(Container.i18n().format(I18n.ASSETS_STATE_READY, projection.visibleAssetCount(), assetCount)); + navigatorStateLabel.setText(Container.i18n().format(I18n.ASSETS_STATE_READY, projection.visibleAssetCount(), state.assets().size())); renderNavigatorProjection(projection); } - 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 void renderDetails() { + detailsContent.getChildren().clear(); + switch (state.status()) { + case LOADING -> { + workspaceSummaryLabel.setText(Container.i18n().text(I18n.ASSETS_SUMMARY_LOADING)); + detailsContent.getChildren().add(createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING))); + } + case EMPTY -> { + workspaceSummaryLabel.setText(Container.i18n().text(I18n.ASSETS_SUMMARY_EMPTY)); + detailsContent.getChildren().add(createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY))); + } + case ERROR -> { + workspaceSummaryLabel.setText(Container.i18n().text(I18n.ASSETS_SUMMARY_ERROR)); + detailsContent.getChildren().add(createSectionMessage(state.errorMessage())); + } + case READY -> { + workspaceSummaryLabel.setText(Container.i18n().format(I18n.ASSETS_SUMMARY_READY, state.assets().size())); + state.selectedAsset() + .ifPresentOrElse(this::renderSelectedAssetDetails, () -> + detailsContent.getChildren().add(createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_NO_SELECTION)))); + } + } + } + + private void renderSelectedAssetDetails(AssetWorkspaceAssetSummary summary) { + detailsContent.getChildren().add(createSummarySection(summary)); + if (detailsStatus == AssetWorkspaceDetailsStatus.LOADING) { + detailsContent.getChildren().add(createSection( + Container.i18n().text(I18n.ASSETS_SECTION_RUNTIME_CONTRACT), + createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING)))); + detailsContent.getChildren().add(createSection( + Container.i18n().text(I18n.ASSETS_SECTION_INPUTS_PREVIEW), + createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING)))); + detailsContent.getChildren().add(createSection( + Container.i18n().text(I18n.ASSETS_SECTION_DIAGNOSTICS), + createSectionMessage(Container.i18n().text(I18n.ASSETS_DETAILS_LOADING)))); + detailsContent.getChildren().add(createSection( + Container.i18n().text(I18n.ASSETS_SECTION_ACTIONS), + createActionsContent(summary))); + return; + } + + if (detailsStatus == AssetWorkspaceDetailsStatus.ERROR || selectedAssetDetails == null) { + detailsContent.getChildren().add(createSection( + Container.i18n().text(I18n.ASSETS_SECTION_RUNTIME_CONTRACT), + createSectionMessage(Objects.requireNonNullElse(detailsErrorMessage, Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY))))); + detailsContent.getChildren().add(createSection( + Container.i18n().text(I18n.ASSETS_SECTION_INPUTS_PREVIEW), + createSectionMessage(Objects.requireNonNullElse(detailsErrorMessage, Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY))))); + detailsContent.getChildren().add(createSection( + Container.i18n().text(I18n.ASSETS_SECTION_DIAGNOSTICS), + createSectionMessage(Objects.requireNonNullElse(detailsErrorMessage, Container.i18n().text(I18n.ASSETS_DETAILS_EMPTY))))); + detailsContent.getChildren().add(createSection( + Container.i18n().text(I18n.ASSETS_SECTION_ACTIONS), + createActionsContent(summary))); + return; + } + + detailsContent.getChildren().add(createRuntimeContractSection(selectedAssetDetails)); + detailsContent.getChildren().add(createInputsPreviewSection(selectedAssetDetails)); + detailsContent.getChildren().add(createDiagnosticsSection(selectedAssetDetails)); + detailsContent.getChildren().add(createSection( + Container.i18n().text(I18n.ASSETS_SECTION_ACTIONS), + createActionsContent(summary))); + } + + private Node createSummarySection(AssetWorkspaceAssetSummary summary) { + final VBox content = new VBox(8); + content.getChildren().addAll( + createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_NAME), summary.assetName()), + createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_STATE), summary.state().name().toLowerCase()), + createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_ASSET_ID), summary.assetId() == null ? "—" : String.valueOf(summary.assetId())), + createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_TYPE), summary.assetFamily()), + createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), summary.assetRoot().toString())); + return createSection(Container.i18n().text(I18n.ASSETS_SECTION_SUMMARY), content); + } + + private Node createRuntimeContractSection(AssetWorkspaceAssetDetails details) { + final VBox content = new VBox(8); + content.getChildren().addAll( + createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_FORMAT), details.outputFormat()), + createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_CODEC), details.outputCodec()), + createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_PRELOAD), yesNo(details.summary().preload()))); + return createSection(Container.i18n().text(I18n.ASSETS_SECTION_RUNTIME_CONTRACT), content); + } + + private Node createInputsPreviewSection(AssetWorkspaceAssetDetails details) { + if (details.inputsByRole().isEmpty()) { + return createSection( + Container.i18n().text(I18n.ASSETS_SECTION_INPUTS_PREVIEW), + createSectionMessage(Container.i18n().text(I18n.ASSETS_INPUTS_EMPTY))); + } + + if (selectedPreviewInput == null || !containsInput(details, selectedPreviewInput)) { + selectedPreviewInput = firstPreviewInput(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().add("assets-details-input-button"); + if (input.equals(selectedPreviewInput)) { + inputButton.getStyleClass().add("assets-details-input-button-selected"); + } + inputButton.setMaxWidth(Double.MAX_VALUE); + inputButton.setOnAction(event -> { + selectedPreviewInput = input; + renderState(); + }); + roleBox.getChildren().add(inputButton); + } + inputsList.getChildren().add(roleBox); + } + + final Node previewPane = createPreviewPane(selectedPreviewInput); + final SplitPane splitPane = new SplitPane(inputsList, previewPane); + splitPane.setOrientation(Orientation.HORIZONTAL); + splitPane.setDividerPositions(0.34); + splitPane.getStyleClass().add("assets-details-input-preview-split"); + return createSection(Container.i18n().text(I18n.ASSETS_SECTION_INPUTS_PREVIEW), splitPane); + } + + private Node createDiagnosticsSection(AssetWorkspaceAssetDetails details) { + if (details.diagnostics().isEmpty()) { + return createSection( + Container.i18n().text(I18n.ASSETS_SECTION_DIAGNOSTICS), + createSectionMessage(Container.i18n().text(I18n.ASSETS_DIAGNOSTICS_EMPTY))); + } + + final VBox diagnosticsBox = new VBox(8); + for (AssetWorkspaceDiagnostic diagnostic : details.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); + } + return createSection(Container.i18n().text(I18n.ASSETS_SECTION_DIAGNOSTICS), diagnosticsBox); + } + + private Node createActionsContent(AssetWorkspaceAssetSummary summary) { + final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(summary); + final VBox content = new VBox(12); + + final VBox primaryBox = new VBox(8); + final Label primaryLabel = new Label(Container.i18n().text(I18n.ASSETS_ACTIONS_PRIMARY)); + primaryLabel.getStyleClass().add("assets-details-subsection-title"); + primaryBox.getChildren().add(primaryLabel); + final FlowPane primaryButtons = new FlowPane(); + primaryButtons.setHgap(8); + primaryButtons.setVgap(8); + for (AssetWorkspaceAction action : actionSet.primaryActions()) { + primaryButtons.getChildren().add(createActionButton(action, false)); + } + primaryBox.getChildren().add(primaryButtons); + content.getChildren().add(primaryBox); + + if (!actionSet.sensitiveActions().isEmpty()) { + final VBox sensitiveBox = new VBox(8); + final Label sensitiveLabel = new Label(Container.i18n().text(I18n.ASSETS_ACTIONS_SENSITIVE)); + sensitiveLabel.getStyleClass().add("assets-details-subsection-title"); + sensitiveBox.getChildren().add(sensitiveLabel); + final FlowPane sensitiveButtons = new FlowPane(); + sensitiveButtons.setHgap(8); + sensitiveButtons.setVgap(8); + for (AssetWorkspaceAction action : actionSet.sensitiveActions()) { + sensitiveButtons.getChildren().add(createActionButton(action, true)); + } + sensitiveBox.getChildren().add(sensitiveButtons); + content.getChildren().add(sensitiveBox); + } + + return content; + } + + private Button createActionButton(AssetWorkspaceAction action, boolean sensitive) { + final Button button = new Button(actionLabel(action)); + button.getStyleClass().add("assets-details-action-button"); + if (sensitive) { + button.getStyleClass().add("assets-details-action-button-sensitive"); + } else { + button.getStyleClass().add("assets-details-action-button-primary"); + } + button.setDisable(true); + return button; + } + + private String actionLabel(AssetWorkspaceAction action) { + return switch (action) { + case DOCTOR -> Container.i18n().text(I18n.ASSETS_ACTION_DOCTOR); + case BUILD -> Container.i18n().text(I18n.ASSETS_ACTION_BUILD); + case ADOPT -> Container.i18n().text(I18n.ASSETS_ACTION_ADOPT); + case REGISTER -> Container.i18n().text(I18n.ASSETS_ACTION_REGISTER); + case FORGET -> Container.i18n().text(I18n.ASSETS_ACTION_FORGET); + case REMOVE -> Container.i18n().text(I18n.ASSETS_ACTION_REMOVE); + }; + } + + private Node createPreviewPane(Path input) { + final VBox previewBox = new VBox(10); + previewBox.getStyleClass().add("assets-details-preview-pane"); + if (input == null) { + previewBox.getChildren().add(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 = extensionOf(input); + if (isImage(extension)) { + try { + final Image image = new Image(input.toUri().toString(), true); + final ImageView imageView = new ImageView(image); + imageView.setPreserveRatio(true); + imageView.setFitWidth(420); + previewBox.getChildren().add(imageView); + } catch (RuntimeException runtimeException) { + previewBox.getChildren().add(createSectionMessage(Container.i18n().text(I18n.ASSETS_PREVIEW_IMAGE_ERROR))); + } + previewBox.getChildren().add(createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), input.toString())); + return previewBox; + } + + if (isText(extension)) { + final TextArea textArea = new TextArea(readPreviewText(input)); + textArea.setWrapText(true); + textArea.setEditable(false); + textArea.getStyleClass().add("assets-details-preview-text"); + previewBox.getChildren().add(textArea); + return previewBox; + } + + if (isAudio(extension)) { + previewBox.getChildren().add(createSectionMessage(Container.i18n().format(I18n.ASSETS_PREVIEW_AUDIO_PLACEHOLDER, input.getFileName().toString()))); + previewBox.getChildren().add(createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), input.toString())); + return previewBox; + } + + previewBox.getChildren().add(createSectionMessage(Container.i18n().format(I18n.ASSETS_PREVIEW_GENERIC_PLACEHOLDER, input.getFileName().toString()))); + previewBox.getChildren().add(createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), input.toString())); + return previewBox; + } + + private Node 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; + } + + 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 HBox row = new HBox(12); + row.setAlignment(Pos.TOP_LEFT); + final Label keyLabel = new Label(key); + keyLabel.getStyleClass().add("assets-details-key"); + final Label valueLabel = new Label(value); + valueLabel.getStyleClass().add("assets-details-value"); + valueLabel.setWrapText(true); + HBox.setHgrow(valueLabel, Priority.ALWAYS); + row.getChildren().addAll(keyLabel, valueLabel); + return row; + } + private void renderNavigatorProjection(AssetNavigatorProjection projection) { navigatorContent.getChildren().clear(); for (AssetNavigatorGroup group : projection.groups()) { @@ -239,7 +568,7 @@ public final class AssetWorkspace implements Workspace { } final HBox topLine = new HBox(8); - topLine.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + topLine.setAlignment(Pos.CENTER_LEFT); final Label icon = new Label(assetIcon(asset)); icon.getStyleClass().add("assets-workspace-asset-icon"); final Label name = new Label(asset.assetName()); @@ -290,6 +619,65 @@ public final class AssetWorkspace implements Workspace { state = state.withSelection(selectionKey); renderState(); workspaceBus.publish(new StudioAssetsWorkspaceSelectionChangedEvent(projectReference, selectionKey)); + loadSelectedAssetDetails(selectionKey); + } + + private Path firstPreviewInput(AssetWorkspaceAssetDetails details) { + return details.inputsByRole().values().stream() + .flatMap(List::stream) + .findFirst() + .orElse(null); + } + + private boolean containsInput(AssetWorkspaceAssetDetails details, Path input) { + return details.inputsByRole().values().stream().flatMap(List::stream).anyMatch(input::equals); + } + + private 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 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(); + } + + private boolean isImage(String extension) { + return extension.equals("png") + || extension.equals("jpg") + || extension.equals("jpeg") + || extension.equals("gif") + || extension.equals("bmp"); + } + + private boolean isText(String extension) { + return extension.equals("json") + || extension.equals("txt") + || extension.equals("pal") + || extension.equals("csv") + || extension.equals("yaml") + || extension.equals("yml") + || extension.equals("xml") + || extension.equals("pbs"); + } + + private boolean isAudio(String extension) { + return extension.equals("wav") + || extension.equals("ogg") + || extension.equals("mp3"); + } + + private String yesNo(boolean value) { + return value ? Container.i18n().text(I18n.ASSETS_VALUE_YES) : Container.i18n().text(I18n.ASSETS_VALUE_NO); } private String assetIcon(AssetWorkspaceAssetSummary asset) { 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 new file mode 100644 index 00000000..23372cc4 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAction.java @@ -0,0 +1,10 @@ +package p.studio.workspaces.assets; + +public enum AssetWorkspaceAction { + DOCTOR, + BUILD, + ADOPT, + REGISTER, + FORGET, + 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 new file mode 100644 index 00000000..42693dd6 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSet.java @@ -0,0 +1,13 @@ +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 new file mode 100644 index 00000000..2702600d --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilder.java @@ -0,0 +1,21 @@ +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 MANAGED -> new AssetWorkspaceActionSet( + List.of(AssetWorkspaceAction.DOCTOR, AssetWorkspaceAction.BUILD), + List.of(AssetWorkspaceAction.FORGET, AssetWorkspaceAction.REMOVE)); + case ORPHAN -> new AssetWorkspaceActionSet( + List.of(AssetWorkspaceAction.ADOPT, AssetWorkspaceAction.REGISTER), + List.of()); + }; + } +} 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 new file mode 100644 index 00000000..b493258e --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAssetDetails.java @@ -0,0 +1,22 @@ +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/AssetWorkspaceDetailsStatus.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDetailsStatus.java new file mode 100644 index 00000000..3fcfacad --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDetailsStatus.java @@ -0,0 +1,8 @@ +package p.studio.workspaces.assets; + +public enum AssetWorkspaceDetailsStatus { + EMPTY, + LOADING, + READY, + ERROR +} 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 new file mode 100644 index 00000000..9dbf9156 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDiagnostic.java @@ -0,0 +1,16 @@ +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 new file mode 100644 index 00000000..1c7c2c75 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceDiagnosticSeverity.java @@ -0,0 +1,7 @@ +package p.studio.workspaces.assets; + +public enum AssetWorkspaceDiagnosticSeverity { + BLOCKER, + WARNING, + HINT +} 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 index 1cae10bf..4bacdd1d 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceService.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceService.java @@ -4,4 +4,6 @@ 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/FileSystemAssetWorkspaceService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceService.java index fc7891e5..c2dfc25c 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceService.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceService.java @@ -1,6 +1,7 @@ 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; @@ -37,6 +38,33 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ } } + @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 { @@ -102,6 +130,51 @@ public final class FileSystemAssetWorkspaceService implements AssetWorkspaceServ } } + 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()) + .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) { } diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index 341e327b..c84b923d 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -84,6 +84,36 @@ assets.badge.managed=Managed assets.badge.orphan=Orphan assets.badge.preload=Preload assets.badge.diagnostics=Diagnostics +assets.section.summary=Summary +assets.section.runtimeContract=Runtime Contract +assets.section.inputsPreview=Inputs / Preview +assets.section.diagnostics=Diagnostics +assets.section.actions=Actions +assets.actions.primary=Primary Actions +assets.actions.sensitive=Sensitive Actions +assets.action.doctor=Doctor +assets.action.build=Build +assets.action.adopt=Adopt +assets.action.register=Register +assets.action.forget=Forget +assets.action.remove=Remove +assets.label.name=Name +assets.label.state=State +assets.label.assetId=Asset ID +assets.label.type=Type +assets.label.location=Location +assets.label.format=Format +assets.label.codec=Codec +assets.label.preload=Preload +assets.value.yes=Yes +assets.value.no=No +assets.inputs.empty=No previewable inputs are currently declared for this asset. +assets.diagnostics.empty=No diagnostics are currently attached to this asset. +assets.preview.empty=Select an input to preview it here. +assets.preview.textError=Unable to read this text-like input for preview. +assets.preview.imageError=Unable to decode this image for preview. +assets.preview.audioPlaceholder=Audio preview placeholder: {0} +assets.preview.genericPlaceholder=Preview placeholder for {0} assets.summary.loading=Hydrating asset workspace state... assets.summary.empty=No assets are currently available. assets.summary.ready=Navigator ready with {0} assets. diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index d2dcb1da..13bfe0de 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -272,6 +272,151 @@ -fx-padding: 10 0 0 0; } +.assets-workspace-details-scroll { + -fx-background-color: transparent; + -fx-fit-to-width: true; +} + +.assets-workspace-details-scroll > .viewport { + -fx-background-color: transparent; +} + +.assets-workspace-details-content { + -fx-padding: 4 0 4 0; +} + +.assets-details-section { + -fx-background-color: #11151b; + -fx-background-radius: 12; + -fx-border-radius: 12; + -fx-border-color: #26313d; + -fx-padding: 12; +} + +.assets-details-section-title { + -fx-text-fill: #f5f9ff; + -fx-font-size: 14px; + -fx-font-weight: bold; +} + +.assets-details-subsection-title { + -fx-text-fill: #b8d7f6; + -fx-font-size: 12px; + -fx-font-weight: bold; +} + +.assets-details-section-message { + -fx-text-fill: #cbd4de; + -fx-font-size: 12px; +} + +.assets-details-key { + -fx-text-fill: #8fa5bc; + -fx-font-size: 11px; + -fx-min-width: 88; + -fx-font-weight: bold; +} + +.assets-details-value { + -fx-text-fill: #eef4fb; + -fx-font-size: 12px; +} + +.assets-details-role-label { + -fx-text-fill: #9fc3e7; + -fx-font-size: 11px; + -fx-font-weight: bold; +} + +.assets-details-input-button { + -fx-background-color: #17202a; + -fx-text-fill: #e6eff8; + -fx-background-radius: 8; + -fx-border-radius: 8; + -fx-border-color: #2f4053; + -fx-alignment: center-left; +} + +.assets-details-input-button-selected { + -fx-background-color: #224160; + -fx-border-color: #4f8dc3; +} + +.assets-details-input-preview-split { + -fx-background-color: transparent; +} + +.assets-details-preview-pane { + -fx-background-color: #0f1318; + -fx-background-radius: 10; + -fx-padding: 10; + -fx-spacing: 10; +} + +.assets-details-preview-title { + -fx-text-fill: #f2f8ff; + -fx-font-size: 13px; + -fx-font-weight: bold; +} + +.assets-details-preview-text { + -fx-control-inner-background: #10161d; + -fx-text-fill: #e4edf6; + -fx-highlight-fill: #2c5e91; +} + +.assets-details-diagnostic-card { + -fx-background-radius: 10; + -fx-border-radius: 10; + -fx-border-width: 1; + -fx-padding: 10; +} + +.assets-details-diagnostic-blocker { + -fx-background-color: #3d181b; + -fx-border-color: #c15b64; +} + +.assets-details-diagnostic-warning { + -fx-background-color: #3e3117; + -fx-border-color: #c89b43; +} + +.assets-details-diagnostic-hint { + -fx-background-color: #173241; + -fx-border-color: #5f99c5; +} + +.assets-details-diagnostic-severity { + -fx-text-fill: #ffffff; + -fx-font-size: 11px; + -fx-font-weight: bold; +} + +.assets-details-diagnostic-message { + -fx-text-fill: #eef4fb; + -fx-font-size: 12px; +} + +.assets-details-action-button { + -fx-background-radius: 10; + -fx-border-radius: 10; + -fx-border-width: 1; + -fx-padding: 8 12 8 12; +} + +.assets-details-action-button-primary { + -fx-background-color: #173322; + -fx-border-color: #2f8f59; + -fx-text-fill: #c7f8d7; +} + +.assets-details-action-button-sensitive { + -fx-background-color: #3b191c; + -fx-border-color: #c7606a; + -fx-text-fill: #ffd6d9; +} + .studio-project-launcher { -fx-background-color: linear-gradient(to bottom, #20242c, #14181d); } 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 new file mode 100644 index 00000000..e1f54774 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilderTest.java @@ -0,0 +1,42 @@ +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 managedAssetsExposeDoctorBuildAndSensitiveMutations() { + final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(new AssetWorkspaceAssetSummary( + new AssetWorkspaceSelectionKey.ManagedAsset(42), + "ui_atlas", + AssetWorkspaceAssetState.MANAGED, + 42, + "image_bank", + Path.of("/tmp/assets/ui_atlas"), + true, + false)); + + assertEquals(List.of(AssetWorkspaceAction.DOCTOR, AssetWorkspaceAction.BUILD), actionSet.primaryActions()); + assertEquals(List.of(AssetWorkspaceAction.FORGET, AssetWorkspaceAction.REMOVE), actionSet.sensitiveActions()); + } + + @Test + void orphanAssetsExposeAdoptAndRegisterOnly() { + final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(new AssetWorkspaceAssetSummary( + new AssetWorkspaceSelectionKey.OrphanAsset(Path.of("/tmp/assets/ui_sounds")), + "ui_sounds", + AssetWorkspaceAssetState.ORPHAN, + null, + "sound_bank", + Path.of("/tmp/assets/ui_sounds"), + false, + false)); + + assertEquals(List.of(AssetWorkspaceAction.ADOPT, AssetWorkspaceAction.REGISTER), actionSet.primaryActions()); + assertEquals(List.of(), actionSet.sensitiveActions()); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceServiceTest.java index a9497411..bb529bd2 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceServiceTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceServiceTest.java @@ -6,6 +6,7 @@ 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; @@ -82,6 +83,57 @@ final class FileSystemAssetWorkspaceServiceTest { assertEquals(null, orphan.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); }