implements PR-05a assets workspace foundation
This commit is contained in:
parent
3c5f949910
commit
75dc37d7e7
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -78,6 +78,20 @@ public enum I18n {
|
|||||||
WORKSPACE_SHIPPER_BUTTON_CLEAR("workspace.shipper.button.clear"),
|
WORKSPACE_SHIPPER_BUTTON_CLEAR("workspace.shipper.button.clear"),
|
||||||
|
|
||||||
WORKSPACE_ASSETS("workspace.assets"),
|
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"),
|
WORKSPACE_DEBUG("workspace.debug"),
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import p.studio.utilities.i18n.I18n;
|
|||||||
import p.studio.workspaces.PlaceholderWorkspace;
|
import p.studio.workspaces.PlaceholderWorkspace;
|
||||||
import p.studio.workspaces.WorkspaceHost;
|
import p.studio.workspaces.WorkspaceHost;
|
||||||
import p.studio.workspaces.WorkspaceId;
|
import p.studio.workspaces.WorkspaceId;
|
||||||
|
import p.studio.workspaces.assets.AssetWorkspace;
|
||||||
import p.studio.workspaces.builder.BuilderWorkspace;
|
import p.studio.workspaces.builder.BuilderWorkspace;
|
||||||
import p.studio.workspaces.editor.EditorWorkspace;
|
import p.studio.workspaces.editor.EditorWorkspace;
|
||||||
|
|
||||||
@ -25,7 +26,7 @@ public final class MainView extends BorderPane {
|
|||||||
setTop(new StudioShellTopBarControl(menuBar));
|
setTop(new StudioShellTopBarControl(menuBar));
|
||||||
|
|
||||||
host.register(new EditorWorkspace());
|
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 BuilderWorkspace(projectReference));
|
||||||
host.register(new PlaceholderWorkspace(WorkspaceId.DEBUG, I18n.WORKSPACE_DEBUG, "Debug"));
|
host.register(new PlaceholderWorkspace(WorkspaceId.DEBUG, I18n.WORKSPACE_DEBUG, "Debug"));
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package p.studio.workspaces.assets;
|
||||||
|
|
||||||
|
public enum AssetWorkspaceAssetState {
|
||||||
|
MANAGED,
|
||||||
|
ORPHAN
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package p.studio.workspaces.assets;
|
||||||
|
|
||||||
|
import p.studio.projects.ProjectReference;
|
||||||
|
|
||||||
|
public interface AssetWorkspaceService {
|
||||||
|
AssetWorkspaceSnapshot loadWorkspace(ProjectReference projectReference);
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package p.studio.workspaces.assets;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record AssetWorkspaceSnapshot(List<AssetWorkspaceAssetSummary> assets) {
|
||||||
|
public AssetWorkspaceSnapshot {
|
||||||
|
assets = List.copyOf(Objects.requireNonNull(assets, "assets"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<AssetWorkspaceAssetSummary> 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<AssetWorkspaceAssetSummary> assets, AssetWorkspaceSelectionKey preferredSelectionKey) {
|
||||||
|
final List<AssetWorkspaceAssetSummary> 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<AssetWorkspaceAssetSummary> 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<AssetWorkspaceAssetSummary> 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<AssetWorkspaceAssetSummary> normalizeAssets(List<AssetWorkspaceAssetSummary> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package p.studio.workspaces.assets;
|
||||||
|
|
||||||
|
public enum AssetWorkspaceStatus {
|
||||||
|
LOADING,
|
||||||
|
READY,
|
||||||
|
EMPTY,
|
||||||
|
ERROR
|
||||||
|
}
|
||||||
@ -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<Path, Integer> registryByRoot = readRegistry(assetsRoot);
|
||||||
|
try (Stream<Path> paths = Files.find(assetsRoot, Integer.MAX_VALUE, (path, attrs) ->
|
||||||
|
attrs.isRegularFile() && path.getFileName().toString().equals("asset.json"))) {
|
||||||
|
final List<AssetWorkspaceAssetSummary> 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<Path, Integer> 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<Path, Integer> 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<Path, Integer> 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<RegistryEntry> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -68,4 +68,18 @@ workspace.shipper.button.run=Build
|
|||||||
workspace.shipper.button.clear=Clear
|
workspace.shipper.button.clear=Clear
|
||||||
|
|
||||||
workspace.assets=Assets
|
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
|
workspace.debug=Debug
|
||||||
|
|||||||
@ -113,6 +113,36 @@
|
|||||||
-fx-padding: 16;
|
-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 {
|
.studio-project-launcher {
|
||||||
-fx-background-color: linear-gradient(to bottom, #20242c, #14181d);
|
-fx-background-color: linear-gradient(to bottom, #20242c, #14181d);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<AssetWorkspaceAssetSummary> 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<AssetWorkspaceAssetSummary> 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<AssetWorkspaceAssetSummary> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user