implements PR-05a assets workspace foundation

This commit is contained in:
bQUARKz 2026-03-11 16:39:23 +00:00
parent 3c5f949910
commit 75dc37d7e7
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
19 changed files with 738 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
package p.studio.workspaces.assets;
public enum AssetWorkspaceAssetState {
MANAGED,
ORPHAN
}

View File

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

View File

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

View File

@ -0,0 +1,7 @@
package p.studio.workspaces.assets;
import p.studio.projects.ProjectReference;
public interface AssetWorkspaceService {
AssetWorkspaceSnapshot loadWorkspace(ProjectReference projectReference);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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