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_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"),
|
||||
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import p.studio.utilities.i18n.I18n;
|
||||
import p.studio.workspaces.PlaceholderWorkspace;
|
||||
import p.studio.workspaces.WorkspaceHost;
|
||||
import p.studio.workspaces.WorkspaceId;
|
||||
import p.studio.workspaces.assets.AssetWorkspace;
|
||||
import p.studio.workspaces.builder.BuilderWorkspace;
|
||||
import p.studio.workspaces.editor.EditorWorkspace;
|
||||
|
||||
@ -25,7 +26,7 @@ public final class MainView extends BorderPane {
|
||||
setTop(new StudioShellTopBarControl(menuBar));
|
||||
|
||||
host.register(new EditorWorkspace());
|
||||
host.register(new PlaceholderWorkspace(WorkspaceId.ASSETS, I18n.WORKSPACE_ASSETS, "Assets"));
|
||||
host.register(new AssetWorkspace(projectReference));
|
||||
host.register(new BuilderWorkspace(projectReference));
|
||||
host.register(new PlaceholderWorkspace(WorkspaceId.DEBUG, I18n.WORKSPACE_DEBUG, "Debug"));
|
||||
|
||||
|
||||
@ -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.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
|
||||
|
||||
@ -113,6 +113,36 @@
|
||||
-fx-padding: 16;
|
||||
}
|
||||
|
||||
.assets-workspace {
|
||||
-fx-background-color: #17191d;
|
||||
}
|
||||
|
||||
.assets-workspace-split {
|
||||
-fx-background-color: transparent;
|
||||
}
|
||||
|
||||
.assets-workspace-pane {
|
||||
-fx-padding: 18;
|
||||
-fx-spacing: 10;
|
||||
-fx-background-color: #1b1f25;
|
||||
}
|
||||
|
||||
.assets-workspace-pane-title {
|
||||
-fx-text-fill: #f3f7fb;
|
||||
-fx-font-size: 15px;
|
||||
-fx-font-weight: bold;
|
||||
}
|
||||
|
||||
.assets-workspace-summary {
|
||||
-fx-text-fill: #9ecbff;
|
||||
-fx-font-size: 12px;
|
||||
}
|
||||
|
||||
.assets-workspace-pane-body {
|
||||
-fx-text-fill: #d3dbe5;
|
||||
-fx-font-size: 12px;
|
||||
}
|
||||
|
||||
.studio-project-launcher {
|
||||
-fx-background-color: linear-gradient(to bottom, #20242c, #14181d);
|
||||
}
|
||||
|
||||
@ -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