implements PR-05c selected asset details contract and preview

This commit is contained in:
bQUARKz 2026-03-11 16:48:08 +00:00
parent 17064aa505
commit 512e4eb49e
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
15 changed files with 894 additions and 35 deletions

View File

@ -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"),

View File

@ -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<AssetNavigatorFilter, ToggleButton> filterButtons = new EnumMap<>(AssetNavigatorFilter.class);
private final EnumSet<AssetNavigatorFilter> 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<String, List<Path>> 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) {

View File

@ -0,0 +1,10 @@
package p.studio.workspaces.assets;
public enum AssetWorkspaceAction {
DOCTOR,
BUILD,
ADOPT,
REGISTER,
FORGET,
REMOVE
}

View File

@ -0,0 +1,13 @@
package p.studio.workspaces.assets;
import java.util.List;
import java.util.Objects;
public record AssetWorkspaceActionSet(
List<AssetWorkspaceAction> primaryActions,
List<AssetWorkspaceAction> sensitiveActions) {
public AssetWorkspaceActionSet {
primaryActions = List.copyOf(Objects.requireNonNull(primaryActions, "primaryActions"));
sensitiveActions = List.copyOf(Objects.requireNonNull(sensitiveActions, "sensitiveActions"));
}
}

View File

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

View File

@ -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<String, List<Path>> inputsByRole,
List<AssetWorkspaceDiagnostic> 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"));
}
}

View File

@ -0,0 +1,8 @@
package p.studio.workspaces.assets;
public enum AssetWorkspaceDetailsStatus {
EMPTY,
LOADING,
READY,
ERROR
}

View File

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

View File

@ -0,0 +1,7 @@
package p.studio.workspaces.assets;
public enum AssetWorkspaceDiagnosticSeverity {
BLOCKER,
WARNING,
HINT
}

View File

@ -4,4 +4,6 @@ import p.studio.projects.ProjectReference;
public interface AssetWorkspaceService {
AssetWorkspaceSnapshot loadWorkspace(ProjectReference projectReference);
AssetWorkspaceAssetDetails loadAssetDetails(ProjectReference projectReference, AssetWorkspaceSelectionKey selectionKey);
}

View File

@ -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<Path, Integer> 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<String, List<Path>> 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<Path, Integer> 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<Path, Integer> 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<String, List<Path>> readInputs(JsonNode inputsNode, Path assetRoot) {
if (inputsNode == null || !inputsNode.isObject()) {
return Map.of();
}
final Map<String, List<Path>> result = new LinkedHashMap<>();
final Iterator<Map.Entry<String, JsonNode>> fields = inputsNode.fields();
while (fields.hasNext()) {
final Map.Entry<String, JsonNode> entry = fields.next();
if (!entry.getValue().isArray()) {
continue;
}
final List<Path> 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) {
}

View File

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

View File

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

View File

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

View File

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