From edf3e6d3ecd5c9190bc00fe961bf568c586926a7 Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Wed, 11 Mar 2026 17:16:13 +0000 Subject: [PATCH] assets workspace 5e --- .../shell/StudioActivityEventMapper.java | 6 + .../shell/StudioActivityFeedControl.java | 3 + .../StudioAssetsMutationAppliedEvent.java | 16 + .../StudioAssetsMutationFailedEvent.java | 17 + ...StudioAssetsMutationPreviewReadyEvent.java | 16 + .../java/p/studio/utilities/i18n/I18n.java | 21 ++ .../workspaces/assets/AssetWorkspace.java | 164 ++++++++- .../assets/AssetWorkspaceAction.java | 2 + .../AssetWorkspaceActionSetBuilder.java | 10 +- .../assets/AssetWorkspaceMutationChange.java | 18 + .../AssetWorkspaceMutationChangeScope.java | 6 + ...AssetWorkspaceMutationImpactViewModel.java | 25 ++ .../assets/AssetWorkspaceMutationPreview.java | 30 ++ .../assets/AssetWorkspaceMutationService.java | 9 + ...leSystemAssetWorkspaceMutationService.java | 334 ++++++++++++++++++ .../main/resources/i18n/messages.properties | 21 ++ .../resources/themes/default-prometeu.css | 56 +++ .../shell/StudioActivityEventMapperTest.java | 38 ++ .../AssetWorkspaceActionSetBuilderTest.java | 12 +- ...tWorkspaceMutationImpactViewModelTest.java | 41 +++ ...stemAssetWorkspaceMutationServiceTest.java | 144 ++++++++ test-projects/tototo/prometeu.json | 8 - test-projects/umbelivable/prometeu.json | 8 - 23 files changed, 981 insertions(+), 24 deletions(-) create mode 100644 prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationAppliedEvent.java create mode 100644 prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationFailedEvent.java create mode 100644 prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationPreviewReadyEvent.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationChange.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationChangeScope.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModel.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationPreview.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationService.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationService.java create mode 100644 prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModelTest.java create mode 100644 prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationServiceTest.java delete mode 100644 test-projects/tototo/prometeu.json delete mode 100644 test-projects/umbelivable/prometeu.json diff --git a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityEventMapper.java b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityEventMapper.java index 5d33176f..72fb2f96 100644 --- a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityEventMapper.java +++ b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityEventMapper.java @@ -24,6 +24,12 @@ public final class StudioActivityEventMapper { Optional.of(new StudioActivityEntry("Assets", refreshed.assetCount() + " assets loaded", StudioActivityEntrySeverity.SUCCESS, false)); case StudioAssetsWorkspaceRefreshFailedEvent failed -> Optional.of(new StudioActivityEntry("Assets", failed.message(), StudioActivityEntrySeverity.ERROR, true)); + case StudioAssetsMutationPreviewReadyEvent previewReady -> + Optional.of(new StudioActivityEntry("Assets", "Preview ready: " + previewReady.action().name().toLowerCase(), StudioActivityEntrySeverity.INFO, false)); + case StudioAssetsMutationAppliedEvent applied -> + Optional.of(new StudioActivityEntry("Assets", "Action applied: " + applied.action().name().toLowerCase(), StudioActivityEntrySeverity.SUCCESS, false)); + case StudioAssetsMutationFailedEvent failed -> + Optional.of(new StudioActivityEntry("Assets", failed.message(), StudioActivityEntrySeverity.ERROR, true)); default -> Optional.empty(); }; } diff --git a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityFeedControl.java b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityFeedControl.java index a8d36299..6907f01b 100644 --- a/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityFeedControl.java +++ b/prometeu-studio/src/main/java/p/studio/controls/shell/StudioActivityFeedControl.java @@ -56,6 +56,9 @@ public final class StudioActivityFeedControl extends VBox implements StudioContr subscriptions.add(eventBus.subscribe(StudioAssetsWorkspaceRefreshStartedEvent.class, this::onAssetsRefreshStarted)); subscriptions.add(eventBus.subscribe(StudioAssetsWorkspaceRefreshedEvent.class, this::onAssetsRefreshFinished)); subscriptions.add(eventBus.subscribe(StudioAssetsWorkspaceRefreshFailedEvent.class, this::onAssetsRefreshFailed)); + subscriptions.add(eventBus.subscribe(StudioAssetsMutationPreviewReadyEvent.class, this::onEvent)); + subscriptions.add(eventBus.subscribe(StudioAssetsMutationAppliedEvent.class, this::onEvent)); + subscriptions.add(eventBus.subscribe(StudioAssetsMutationFailedEvent.class, this::onEvent)); } @Override diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationAppliedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationAppliedEvent.java new file mode 100644 index 00000000..54192837 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationAppliedEvent.java @@ -0,0 +1,16 @@ +package p.studio.events; + +import p.studio.projects.ProjectReference; +import p.studio.workspaces.assets.AssetWorkspaceAction; + +import java.util.Objects; + +public record StudioAssetsMutationAppliedEvent( + ProjectReference project, + AssetWorkspaceAction action, + int affectedAssets) implements StudioEvent { + public StudioAssetsMutationAppliedEvent { + Objects.requireNonNull(project, "project"); + Objects.requireNonNull(action, "action"); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationFailedEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationFailedEvent.java new file mode 100644 index 00000000..cab75386 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationFailedEvent.java @@ -0,0 +1,17 @@ +package p.studio.events; + +import p.studio.projects.ProjectReference; +import p.studio.workspaces.assets.AssetWorkspaceAction; + +import java.util.Objects; + +public record StudioAssetsMutationFailedEvent( + ProjectReference project, + AssetWorkspaceAction action, + String message) implements StudioEvent { + public StudioAssetsMutationFailedEvent { + Objects.requireNonNull(project, "project"); + Objects.requireNonNull(action, "action"); + Objects.requireNonNull(message, "message"); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationPreviewReadyEvent.java b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationPreviewReadyEvent.java new file mode 100644 index 00000000..9b9fbd79 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/events/StudioAssetsMutationPreviewReadyEvent.java @@ -0,0 +1,16 @@ +package p.studio.events; + +import p.studio.projects.ProjectReference; +import p.studio.workspaces.assets.AssetWorkspaceAction; + +import java.util.Objects; + +public record StudioAssetsMutationPreviewReadyEvent( + ProjectReference project, + AssetWorkspaceAction action, + int affectedAssets) implements StudioEvent { + public StudioAssetsMutationPreviewReadyEvent { + Objects.requireNonNull(project, "project"); + Objects.requireNonNull(action, "action"); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java index f318565f..f09b85fc 100644 --- a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java +++ b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java @@ -105,8 +105,29 @@ public enum I18n { ASSETS_ACTION_BUILD("assets.action.build"), ASSETS_ACTION_ADOPT("assets.action.adopt"), ASSETS_ACTION_REGISTER("assets.action.register"), + ASSETS_ACTION_QUARANTINE("assets.action.quarantine"), + ASSETS_ACTION_RELOCATE("assets.action.relocate"), ASSETS_ACTION_FORGET("assets.action.forget"), ASSETS_ACTION_REMOVE("assets.action.remove"), + ASSETS_MUTATION_PREVIEW_TITLE("assets.mutation.previewTitle"), + ASSETS_MUTATION_SECTION_CHANGES("assets.mutation.section.changes"), + ASSETS_MUTATION_SECTION_AFFECTED_ASSET("assets.mutation.section.affectedAsset"), + ASSETS_MUTATION_SECTION_REGISTRY_IMPACT("assets.mutation.section.registryImpact"), + ASSETS_MUTATION_SECTION_WORKSPACE_IMPACT("assets.mutation.section.workspaceImpact"), + ASSETS_MUTATION_SECTION_BLOCKERS("assets.mutation.section.blockers"), + ASSETS_MUTATION_SECTION_WARNINGS("assets.mutation.section.warnings"), + ASSETS_MUTATION_SECTION_SAFE_FIXES("assets.mutation.section.safeFixes"), + ASSETS_MUTATION_EMPTY_CHANGES("assets.mutation.empty.changes"), + ASSETS_MUTATION_EMPTY_REGISTRY_IMPACT("assets.mutation.empty.registryImpact"), + ASSETS_MUTATION_EMPTY_WORKSPACE_IMPACT("assets.mutation.empty.workspaceImpact"), + ASSETS_MUTATION_EMPTY_BLOCKERS("assets.mutation.empty.blockers"), + ASSETS_MUTATION_EMPTY_WARNINGS("assets.mutation.empty.warnings"), + ASSETS_MUTATION_EMPTY_SAFE_FIXES("assets.mutation.empty.safeFixes"), + ASSETS_MUTATION_CANCEL("assets.mutation.cancel"), + ASSETS_MUTATION_APPLY("assets.mutation.apply"), + ASSETS_MUTATION_CONFIRM_TITLE("assets.mutation.confirm.title"), + ASSETS_MUTATION_CONFIRM_HEADER("assets.mutation.confirm.header"), + ASSETS_MUTATION_CONFIRM_BODY("assets.mutation.confirm.body"), ASSETS_LABEL_NAME("assets.label.name"), ASSETS_LABEL_STATE("assets.label.state"), ASSETS_LABEL_ASSET_ID("assets.label.assetId"), diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java index 9549a6e5..73a53b6c 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java @@ -30,6 +30,7 @@ public final class AssetWorkspace implements Workspace { private final BorderPane root = new BorderPane(); private final ProjectReference projectReference; private final AssetWorkspaceService assetWorkspaceService; + private final AssetWorkspaceMutationService mutationService; private final StudioWorkspaceEventBus workspaceBus; private final TextField searchField = new TextField(); @@ -50,16 +51,21 @@ public final class AssetWorkspace implements Workspace { private volatile AssetWorkspaceDetailsStatus detailsStatus = AssetWorkspaceDetailsStatus.EMPTY; private volatile AssetWorkspaceAssetDetails selectedAssetDetails; private volatile String detailsErrorMessage; + private volatile AssetWorkspaceMutationPreview stagedMutationPreview; private volatile Path selectedPreviewInput; private String searchQuery = ""; public AssetWorkspace(ProjectReference projectReference) { - this(projectReference, new FileSystemAssetWorkspaceService()); + this(projectReference, new FileSystemAssetWorkspaceService(), new FileSystemAssetWorkspaceMutationService()); } - public AssetWorkspace(ProjectReference projectReference, AssetWorkspaceService assetWorkspaceService) { + public AssetWorkspace( + ProjectReference projectReference, + AssetWorkspaceService assetWorkspaceService, + AssetWorkspaceMutationService mutationService) { this.projectReference = Objects.requireNonNull(projectReference, "projectReference"); this.assetWorkspaceService = Objects.requireNonNull(assetWorkspaceService, "assetWorkspaceService"); + this.mutationService = Objects.requireNonNull(mutationService, "mutationService"); this.workspaceBus = new StudioWorkspaceEventBus(WorkspaceId.ASSETS, Container.events()); root.getStyleClass().add("assets-workspace"); @@ -197,6 +203,7 @@ public final class AssetWorkspace implements Workspace { detailsStatus = AssetWorkspaceDetailsStatus.EMPTY; selectedAssetDetails = null; detailsErrorMessage = null; + stagedMutationPreview = null; selectedPreviewInput = null; setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_REFRESHING), ProgressBar.INDETERMINATE_PROGRESS, true); appendLog("Assets refresh started."); @@ -233,6 +240,7 @@ public final class AssetWorkspace implements Workspace { detailsStatus = AssetWorkspaceDetailsStatus.LOADING; selectedAssetDetails = null; detailsErrorMessage = null; + stagedMutationPreview = null; selectedPreviewInput = null; setInlineProgress(Container.i18n().text(I18n.ASSETS_PROGRESS_LOADING_DETAILS), ProgressBar.INDETERMINATE_PROGRESS, true); appendLog("Loading details for " + selectionKey.stableKey() + "."); @@ -481,6 +489,10 @@ public final class AssetWorkspace implements Workspace { content.getChildren().add(sensitiveBox); } + if (stagedMutationPreview != null && stagedMutationPreview.asset().selectionKey().equals(summary.selectionKey())) { + content.getChildren().add(createStagedMutationPanel(stagedMutationPreview)); + } + return content; } @@ -492,7 +504,10 @@ public final class AssetWorkspace implements Workspace { } else { button.getStyleClass().add("assets-details-action-button-primary"); } - button.setDisable(true); + button.setDisable(!supportsAction(action)); + if (!button.isDisable()) { + button.setOnAction(event -> requestMutationPreview(action)); + } return button; } @@ -502,6 +517,8 @@ public final class AssetWorkspace implements Workspace { case BUILD -> Container.i18n().text(I18n.ASSETS_ACTION_BUILD); case ADOPT -> Container.i18n().text(I18n.ASSETS_ACTION_ADOPT); case REGISTER -> Container.i18n().text(I18n.ASSETS_ACTION_REGISTER); + case QUARANTINE -> Container.i18n().text(I18n.ASSETS_ACTION_QUARANTINE); + case RELOCATE -> Container.i18n().text(I18n.ASSETS_ACTION_RELOCATE); case FORGET -> Container.i18n().text(I18n.ASSETS_ACTION_FORGET); case REMOVE -> Container.i18n().text(I18n.ASSETS_ACTION_REMOVE); }; @@ -658,12 +675,153 @@ public final class AssetWorkspace implements Workspace { private void selectAsset(AssetWorkspaceSelectionKey selectionKey) { state = state.withSelection(selectionKey); + stagedMutationPreview = null; appendLog("Selected asset " + selectionKey.stableKey() + "."); renderState(); workspaceBus.publish(new StudioAssetsWorkspaceSelectionChangedEvent(projectReference, selectionKey)); loadSelectedAssetDetails(selectionKey); } + private boolean supportsAction(AssetWorkspaceAction action) { + return action == AssetWorkspaceAction.ADOPT + || action == AssetWorkspaceAction.REGISTER + || action == AssetWorkspaceAction.QUARANTINE + || action == AssetWorkspaceAction.RELOCATE + || action == AssetWorkspaceAction.FORGET + || action == AssetWorkspaceAction.REMOVE; + } + + private void requestMutationPreview(AssetWorkspaceAction action) { + final AssetWorkspaceAssetSummary selectedAsset = state.selectedAsset().orElse(null); + if (selectedAsset == null) { + return; + } + final AssetWorkspaceMutationPreview preview = mutationService.preview(projectReference, selectedAsset, action); + stagedMutationPreview = preview; + appendLog("Preview ready for " + actionLabel(action) + "."); + workspaceBus.publish(new StudioAssetsMutationPreviewReadyEvent(projectReference, action, 1)); + renderState(); + } + + private Node createStagedMutationPanel(AssetWorkspaceMutationPreview preview) { + final VBox panel = new VBox(10); + panel.getStyleClass().add("assets-mutation-panel"); + + final Label title = new Label(Container.i18n().format(I18n.ASSETS_MUTATION_PREVIEW_TITLE, actionLabel(preview.action()))); + title.getStyleClass().add("assets-mutation-panel-title"); + panel.getChildren().add(title); + panel.getChildren().add(createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_AFFECTED_ASSET), + createAffectedAssetContent(preview))); + + final AssetWorkspaceMutationImpactViewModel impacts = AssetWorkspaceMutationImpactViewModel.from(preview); + panel.getChildren().add(createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_REGISTRY_IMPACT), + createMutationChangesContent(impacts.registryChanges(), Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_REGISTRY_IMPACT)))); + panel.getChildren().add(createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_WORKSPACE_IMPACT), + createMutationChangesContent(impacts.workspaceChanges(), Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_WORKSPACE_IMPACT)))); + panel.getChildren().add(createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_BLOCKERS), + createMutationMessages(preview.blockers(), "assets-mutation-message-blocker", Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_BLOCKERS)))); + panel.getChildren().add(createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_WARNINGS), + createMutationMessages(preview.warnings(), "assets-mutation-message-warning", Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_WARNINGS)))); + panel.getChildren().add(createMutationSection( + Container.i18n().text(I18n.ASSETS_MUTATION_SECTION_SAFE_FIXES), + createMutationMessages(preview.safeFixes(), "assets-mutation-message-safe-fix", Container.i18n().text(I18n.ASSETS_MUTATION_EMPTY_SAFE_FIXES)))); + + final HBox actions = new HBox(8); + final Button cancel = new Button(Container.i18n().text(I18n.ASSETS_MUTATION_CANCEL)); + cancel.getStyleClass().add("assets-mutation-cancel"); + cancel.setOnAction(event -> { + stagedMutationPreview = null; + renderState(); + }); + final Button apply = new Button(Container.i18n().text(I18n.ASSETS_MUTATION_APPLY)); + apply.getStyleClass().add("assets-mutation-apply"); + apply.setDisable(!preview.canApply()); + apply.setOnAction(event -> applyStagedMutation(preview)); + actions.getChildren().addAll(cancel, apply); + panel.getChildren().add(actions); + + return panel; + } + + private Node createMutationSection(String title, Node content) { + final VBox section = new VBox(6); + final Label label = new Label(title); + label.getStyleClass().add("assets-mutation-section-title"); + section.getChildren().addAll(label, content); + return section; + } + + private Node createAffectedAssetContent(AssetWorkspaceMutationPreview preview) { + final VBox box = new VBox(6); + box.getChildren().add(createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_NAME), preview.asset().assetName())); + box.getChildren().add(createKeyValueRow( + Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), + AssetNavigatorProjectionBuilder.relativeRoot(preview.asset(), assetsRoot()))); + return box; + } + + private Node createMutationChangesContent(List changes, String emptyText) { + if (changes.isEmpty()) { + return createSectionMessage(emptyText); + } + final VBox box = new VBox(6); + for (AssetWorkspaceMutationChange change : changes) { + final Label label = new Label(change.verb() + " ยท " + change.target()); + label.getStyleClass().add("assets-mutation-change"); + box.getChildren().add(label); + } + return box; + } + + private Node createMutationMessages(List messages, String styleClass, String emptyText) { + if (messages.isEmpty()) { + return createSectionMessage(emptyText); + } + final VBox box = new VBox(6); + for (String message : messages) { + final Label label = new Label(message); + label.setWrapText(true); + label.getStyleClass().add(styleClass); + box.getChildren().add(label); + } + return box; + } + + private void applyStagedMutation(AssetWorkspaceMutationPreview preview) { + if (!preview.canApply()) { + return; + } + if (preview.highRisk() && !confirmHighRisk(preview)) { + return; + } + try { + mutationService.apply(projectReference, preview); + appendLog("Applied " + actionLabel(preview.action()) + "."); + workspaceBus.publish(new StudioAssetsMutationAppliedEvent(projectReference, preview.action(), 1)); + stagedMutationPreview = null; + refresh(); + } catch (RuntimeException runtimeException) { + final String message = rootCauseMessage(runtimeException); + appendLog("Mutation failed: " + message); + workspaceBus.publish(new StudioAssetsMutationFailedEvent(projectReference, preview.action(), message)); + stagedMutationPreview = preview; + renderState(); + } + } + + private boolean confirmHighRisk(AssetWorkspaceMutationPreview preview) { + final Alert alert = new Alert(Alert.AlertType.CONFIRMATION); + alert.setTitle(Container.i18n().text(I18n.ASSETS_MUTATION_CONFIRM_TITLE)); + alert.setHeaderText(Container.i18n().format(I18n.ASSETS_MUTATION_CONFIRM_HEADER, actionLabel(preview.action()))); + alert.setContentText(Container.i18n().text(I18n.ASSETS_MUTATION_CONFIRM_BODY)); + return alert.showAndWait().filter(ButtonType.OK::equals).isPresent(); + } + private Path firstPreviewInput(AssetWorkspaceAssetDetails details) { return details.inputsByRole().values().stream() .flatMap(List::stream) diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAction.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAction.java index 23372cc4..786f26ff 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAction.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceAction.java @@ -5,6 +5,8 @@ public enum AssetWorkspaceAction { BUILD, ADOPT, REGISTER, + QUARANTINE, + RELOCATE, FORGET, REMOVE } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilder.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilder.java index 2702600d..7cdb9514 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilder.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilder.java @@ -12,10 +12,16 @@ public final class AssetWorkspaceActionSetBuilder { return switch (summary.state()) { case MANAGED -> new AssetWorkspaceActionSet( List.of(AssetWorkspaceAction.DOCTOR, AssetWorkspaceAction.BUILD), - List.of(AssetWorkspaceAction.FORGET, AssetWorkspaceAction.REMOVE)); + List.of( + AssetWorkspaceAction.FORGET, + AssetWorkspaceAction.QUARANTINE, + AssetWorkspaceAction.RELOCATE, + AssetWorkspaceAction.REMOVE)); case ORPHAN -> new AssetWorkspaceActionSet( List.of(AssetWorkspaceAction.ADOPT, AssetWorkspaceAction.REGISTER), - List.of()); + List.of( + AssetWorkspaceAction.QUARANTINE, + AssetWorkspaceAction.RELOCATE)); }; } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationChange.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationChange.java new file mode 100644 index 00000000..b40e4b62 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationChange.java @@ -0,0 +1,18 @@ +package p.studio.workspaces.assets; + +import java.util.Objects; + +public record AssetWorkspaceMutationChange( + AssetWorkspaceMutationChangeScope scope, + String verb, + String target) { + + public AssetWorkspaceMutationChange { + Objects.requireNonNull(scope, "scope"); + verb = Objects.requireNonNull(verb, "verb").trim(); + target = Objects.requireNonNull(target, "target").trim(); + if (verb.isBlank() || target.isBlank()) { + throw new IllegalArgumentException("verb and target must not be blank"); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationChangeScope.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationChangeScope.java new file mode 100644 index 00000000..f388bcb9 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationChangeScope.java @@ -0,0 +1,6 @@ +package p.studio.workspaces.assets; + +public enum AssetWorkspaceMutationChangeScope { + REGISTRY, + WORKSPACE +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModel.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModel.java new file mode 100644 index 00000000..015310bb --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModel.java @@ -0,0 +1,25 @@ +package p.studio.workspaces.assets; + +import java.util.List; +import java.util.Objects; + +public record AssetWorkspaceMutationImpactViewModel( + List registryChanges, + List workspaceChanges) { + + public AssetWorkspaceMutationImpactViewModel { + registryChanges = List.copyOf(Objects.requireNonNull(registryChanges, "registryChanges")); + workspaceChanges = List.copyOf(Objects.requireNonNull(workspaceChanges, "workspaceChanges")); + } + + public static AssetWorkspaceMutationImpactViewModel from(AssetWorkspaceMutationPreview preview) { + Objects.requireNonNull(preview, "preview"); + return new AssetWorkspaceMutationImpactViewModel( + preview.changes().stream() + .filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.REGISTRY) + .toList(), + preview.changes().stream() + .filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.WORKSPACE) + .toList()); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationPreview.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationPreview.java new file mode 100644 index 00000000..3c850fcb --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationPreview.java @@ -0,0 +1,30 @@ +package p.studio.workspaces.assets; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +public record AssetWorkspaceMutationPreview( + AssetWorkspaceAction action, + AssetWorkspaceAssetSummary asset, + List blockers, + List warnings, + List safeFixes, + List changes, + boolean highRisk, + Path targetAssetRoot) { + + public AssetWorkspaceMutationPreview { + Objects.requireNonNull(action, "action"); + Objects.requireNonNull(asset, "asset"); + blockers = List.copyOf(Objects.requireNonNull(blockers, "blockers")); + warnings = List.copyOf(Objects.requireNonNull(warnings, "warnings")); + safeFixes = List.copyOf(Objects.requireNonNull(safeFixes, "safeFixes")); + changes = List.copyOf(Objects.requireNonNull(changes, "changes")); + targetAssetRoot = targetAssetRoot == null ? null : targetAssetRoot.toAbsolutePath().normalize(); + } + + public boolean canApply() { + return blockers.isEmpty(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationService.java new file mode 100644 index 00000000..8f5700ac --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspaceMutationService.java @@ -0,0 +1,9 @@ +package p.studio.workspaces.assets; + +import p.studio.projects.ProjectReference; + +public interface AssetWorkspaceMutationService { + AssetWorkspaceMutationPreview preview(ProjectReference projectReference, AssetWorkspaceAssetSummary asset, AssetWorkspaceAction action); + + void apply(ProjectReference projectReference, AssetWorkspaceMutationPreview preview); +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationService.java new file mode 100644 index 00000000..d5e245a3 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationService.java @@ -0,0 +1,334 @@ +package p.studio.workspaces.assets; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +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 FileSystemAssetWorkspaceMutationService implements AssetWorkspaceMutationService { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String PROMETEU_DIR = ".prometeu"; + private static final String QUARANTINE_DIR = "quarantine"; + private static final String RECOVERED_DIR = "recovered"; + + @Override + public AssetWorkspaceMutationPreview preview(ProjectReference projectReference, AssetWorkspaceAssetSummary asset, AssetWorkspaceAction action) { + Objects.requireNonNull(projectReference, "projectReference"); + Objects.requireNonNull(asset, "asset"); + Objects.requireNonNull(action, "action"); + + final List blockers = new ArrayList<>(); + final List warnings = new ArrayList<>(); + final List safeFixes = new ArrayList<>(); + final List changes = new ArrayList<>(); + final Path assetsRoot = projectReference.rootPath().resolve("assets"); + final String relativeRoot = relativeAssetRoot(asset.assetRoot(), assetsRoot); + Path targetAssetRoot = null; + + if (!Files.exists(asset.assetRoot())) { + blockers.add("Asset root does not exist: " + asset.assetRoot()); + } + + switch (action) { + case ADOPT, REGISTER -> { + if (asset.state() == AssetWorkspaceAssetState.MANAGED) { + blockers.add("Asset is already managed."); + } else { + changes.add(new AssetWorkspaceMutationChange( + AssetWorkspaceMutationChangeScope.REGISTRY, + "ADD", + relativeRoot)); + if (asset.hasDiagnostics()) { + warnings.add("Asset currently reports diagnostics and will still be adopted."); + } + } + } + case QUARANTINE -> { + if (isInsideQuarantine(asset.assetRoot(), assetsRoot)) { + blockers.add("Asset is already inside quarantine."); + } else { + targetAssetRoot = nextAvailablePath(quarantineRoot(assetsRoot), sanitizeSegment(asset.assetName())); + if (asset.state() == AssetWorkspaceAssetState.MANAGED) { + changes.add(new AssetWorkspaceMutationChange( + AssetWorkspaceMutationChangeScope.REGISTRY, + "REMOVE", + relativeRoot)); + warnings.add("Quarantining a managed asset removes it from the active registry."); + } + changes.add(new AssetWorkspaceMutationChange( + AssetWorkspaceMutationChangeScope.WORKSPACE, + "MOVE", + relativeRoot + " -> " + relativeAssetRoot(targetAssetRoot, assetsRoot))); + warnings.add("Quarantine is explicit and reversible, but the asset will leave its current workspace location."); + } + } + case RELOCATE -> { + targetAssetRoot = relocationTarget(asset, assetsRoot); + final String targetRelativeRoot = relativeAssetRoot(targetAssetRoot, assetsRoot); + if (asset.assetRoot().equals(targetAssetRoot)) { + blockers.add("Asset is already at the planned relocation target."); + } else { + if (asset.state() == AssetWorkspaceAssetState.MANAGED) { + changes.add(new AssetWorkspaceMutationChange( + AssetWorkspaceMutationChangeScope.REGISTRY, + "UPDATE", + relativeRoot + " -> " + targetRelativeRoot)); + } + changes.add(new AssetWorkspaceMutationChange( + AssetWorkspaceMutationChangeScope.WORKSPACE, + "MOVE", + relativeRoot + " -> " + targetRelativeRoot)); + warnings.add("Relocation preserves asset identity, but it changes the root path seen by the workspace."); + } + } + case FORGET -> { + if (asset.state() != AssetWorkspaceAssetState.MANAGED) { + blockers.add("Only managed assets can be forgotten."); + } else { + changes.add(new AssetWorkspaceMutationChange( + AssetWorkspaceMutationChangeScope.REGISTRY, + "REMOVE", + relativeRoot)); + warnings.add("The asset will leave the managed build set."); + } + } + case REMOVE -> { + if (asset.state() == AssetWorkspaceAssetState.MANAGED) { + changes.add(new AssetWorkspaceMutationChange( + AssetWorkspaceMutationChangeScope.REGISTRY, + "REMOVE", + relativeRoot)); + } + changes.add(new AssetWorkspaceMutationChange( + AssetWorkspaceMutationChangeScope.WORKSPACE, + "DELETE", + relativeRoot)); + warnings.add("Physical files inside the asset root will be deleted."); + } + case DOCTOR, BUILD -> safeFixes.add("This action is handled outside the staged mutation flow."); + } + + final boolean highRisk = action == AssetWorkspaceAction.REMOVE || action == AssetWorkspaceAction.RELOCATE; + return new AssetWorkspaceMutationPreview(action, asset, blockers, warnings, safeFixes, changes, highRisk, targetAssetRoot); + } + + @Override + public void apply(ProjectReference projectReference, AssetWorkspaceMutationPreview preview) { + Objects.requireNonNull(projectReference, "projectReference"); + Objects.requireNonNull(preview, "preview"); + if (!preview.canApply()) { + throw new IllegalStateException("Cannot apply mutation preview with blockers"); + } + + final Path assetsRoot = projectReference.rootPath().resolve("assets"); + final Registry registry = loadRegistry(assetsRoot); + final Path assetRoot = preview.asset().assetRoot(); + + switch (preview.action()) { + case ADOPT, REGISTER -> { + if (registryContainsRoot(registry, assetRoot, assetsRoot)) { + return; + } + final RegistryEntry entry = new RegistryEntry(); + entry.assetId = registry.nextAssetId <= 0 ? 1 : registry.nextAssetId; + entry.assetUuid = UUID.randomUUID().toString(); + entry.root = relativeAssetRoot(assetRoot, assetsRoot); + registry.assets.add(entry); + registry.assets.sort(Comparator.comparingInt(value -> value.assetId)); + registry.nextAssetId = entry.assetId + 1; + writeRegistry(assetsRoot, registry); + } + case FORGET -> { + registry.assets.removeIf(entry -> entry.root != null && assetsRoot.resolve(entry.root).toAbsolutePath().normalize().equals(assetRoot)); + writeRegistry(assetsRoot, registry); + } + case QUARANTINE -> { + final Path targetAssetRoot = requireTargetAssetRoot(preview); + registry.assets.removeIf(entry -> entry.root != null && assetsRoot.resolve(entry.root).toAbsolutePath().normalize().equals(assetRoot)); + writeRegistry(assetsRoot, registry); + moveAssetRoot(assetRoot, targetAssetRoot); + } + case RELOCATE -> { + final Path targetAssetRoot = requireTargetAssetRoot(preview); + if (preview.asset().state() == AssetWorkspaceAssetState.MANAGED) { + registry.assets.stream() + .filter(entry -> entry.root != null && assetsRoot.resolve(entry.root).toAbsolutePath().normalize().equals(assetRoot)) + .findFirst() + .ifPresent(entry -> entry.root = relativeAssetRoot(targetAssetRoot, assetsRoot)); + writeRegistry(assetsRoot, registry); + } + moveAssetRoot(assetRoot, targetAssetRoot); + } + case REMOVE -> { + registry.assets.removeIf(entry -> entry.root != null && assetsRoot.resolve(entry.root).toAbsolutePath().normalize().equals(assetRoot)); + writeRegistry(assetsRoot, registry); + deleteRecursively(assetRoot); + } + case DOCTOR, BUILD -> { + } + } + } + + private Registry loadRegistry(Path assetsRoot) { + final Path registryPath = assetsRoot.resolve(PROMETEU_DIR).resolve("index.json"); + if (!Files.isRegularFile(registryPath)) { + final Registry registry = new Registry(); + registry.schemaVersion = 1; + registry.nextAssetId = 1; + registry.assets = new ArrayList<>(); + return registry; + } + try { + final Registry registry = MAPPER.readValue(registryPath.toFile(), Registry.class); + if (registry.assets == null) { + registry.assets = new ArrayList<>(); + } + if (registry.schemaVersion <= 0) { + registry.schemaVersion = 1; + } + if (registry.nextAssetId <= 0) { + registry.nextAssetId = registry.assets.stream().mapToInt(entry -> entry.assetId).max().orElse(0) + 1; + } + return registry; + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + } + + private void writeRegistry(Path assetsRoot, Registry registry) { + try { + final Path registryDir = assetsRoot.resolve(PROMETEU_DIR); + Files.createDirectories(registryDir); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(registryDir.resolve("index.json").toFile(), registry); + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + } + + private boolean registryContainsRoot(Registry registry, Path assetRoot, Path assetsRoot) { + return registry.assets.stream() + .filter(entry -> entry.root != null) + .map(entry -> assetsRoot.resolve(entry.root).toAbsolutePath().normalize()) + .anyMatch(assetRoot::equals); + } + + private String relativeAssetRoot(Path assetRoot, Path assetsRoot) { + return assetsRoot.relativize(assetRoot.toAbsolutePath().normalize()).toString().replace('\\', '/'); + } + + private Path quarantineRoot(Path assetsRoot) { + return assetsRoot.resolve(PROMETEU_DIR).resolve(QUARANTINE_DIR).toAbsolutePath().normalize(); + } + + private boolean isInsideQuarantine(Path assetRoot, Path assetsRoot) { + return assetRoot.toAbsolutePath().normalize().startsWith(quarantineRoot(assetsRoot)); + } + + private Path relocationTarget(AssetWorkspaceAssetSummary asset, Path assetsRoot) { + final Path assetRoot = asset.assetRoot(); + if (isInsideQuarantine(assetRoot, assetsRoot)) { + return nextAvailablePath(assetsRoot.resolve(RECOVERED_DIR), sanitizeSegment(asset.assetName())); + } + final Path siblingParent = assetRoot.getParent() == null ? assetsRoot : assetRoot.getParent(); + return nextAvailableSibling(siblingParent, assetRoot.getFileName().toString() + "-relocated"); + } + + private Path nextAvailablePath(Path parent, String baseName) { + final Path normalizedParent = parent.toAbsolutePath().normalize(); + Path candidate = normalizedParent.resolve(baseName); + int index = 2; + while (Files.exists(candidate)) { + candidate = normalizedParent.resolve(baseName + "-" + index); + index += 1; + } + return candidate; + } + + private Path nextAvailableSibling(Path parent, String baseName) { + final Path normalizedParent = parent.toAbsolutePath().normalize(); + Path candidate = normalizedParent.resolve(baseName); + int index = 2; + while (Files.exists(candidate)) { + candidate = normalizedParent.resolve(baseName + "-" + index); + index += 1; + } + return candidate; + } + + private String sanitizeSegment(String value) { + final String sanitized = value == null + ? "asset" + : value.trim() + .replaceAll("[^A-Za-z0-9._-]+", "-") + .replaceAll("-{2,}", "-") + .replaceAll("^[.-]+|[.-]+$", ""); + return sanitized == null || sanitized.isBlank() ? "asset" : sanitized; + } + + private Path requireTargetAssetRoot(AssetWorkspaceMutationPreview preview) { + if (preview.targetAssetRoot() == null) { + throw new IllegalStateException("Mutation preview does not define a target asset root"); + } + return preview.targetAssetRoot(); + } + + private void moveAssetRoot(Path sourceRoot, Path targetRoot) { + if (sourceRoot.equals(targetRoot)) { + return; + } + try { + Files.createDirectories(Objects.requireNonNull(targetRoot, "targetRoot").getParent()); + Files.move(sourceRoot, targetRoot); + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + } + + private void deleteRecursively(Path root) { + if (!Files.exists(root)) { + return; + } + try (Stream stream = Files.walk(root)) { + stream.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + }); + } catch (IOException ioException) { + throw new UncheckedIOException(ioException); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class Registry { + @JsonProperty("schema_version") + public int schemaVersion; + + @JsonProperty("next_asset_id") + public int nextAssetId; + + @JsonProperty("assets") + public List assets = new ArrayList<>(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class RegistryEntry { + @JsonProperty("asset_id") + public int assetId; + + @JsonProperty("asset_uuid") + public String assetUuid; + + @JsonProperty("root") + public String root; + } +} diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index daad6666..0d974415 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -95,8 +95,29 @@ assets.action.doctor=Doctor assets.action.build=Build assets.action.adopt=Adopt assets.action.register=Register +assets.action.quarantine=Quarantine +assets.action.relocate=Relocate assets.action.forget=Forget assets.action.remove=Remove +assets.mutation.previewTitle=Preview: {0} +assets.mutation.section.changes=Changes +assets.mutation.section.affectedAsset=Affected Asset +assets.mutation.section.registryImpact=Registry Impact +assets.mutation.section.workspaceImpact=Workspace Impact +assets.mutation.section.blockers=Blockers +assets.mutation.section.warnings=Warnings +assets.mutation.section.safeFixes=Safe Fixes +assets.mutation.empty.changes=No changes are currently staged. +assets.mutation.empty.registryImpact=No registry changes are currently staged. +assets.mutation.empty.workspaceImpact=No workspace changes are currently staged. +assets.mutation.empty.blockers=No blockers. +assets.mutation.empty.warnings=No warnings. +assets.mutation.empty.safeFixes=No safe fixes. +assets.mutation.cancel=Cancel +assets.mutation.apply=Apply +assets.mutation.confirm.title=Confirm High-Risk Mutation +assets.mutation.confirm.header=Confirm {0} +assets.mutation.confirm.body=This mutation is marked as high risk and may change or delete workspace files. assets.label.name=Name assets.label.state=State assets.label.assetId=Asset ID diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index 0959e116..db9a420e 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -480,6 +480,62 @@ -fx-text-fill: #ffd6d9; } +.assets-mutation-panel { + -fx-background-color: #0f1318; + -fx-background-radius: 12; + -fx-border-radius: 12; + -fx-border-color: #2c3745; + -fx-padding: 12; +} + +.assets-mutation-panel-title { + -fx-text-fill: #f6fbff; + -fx-font-size: 13px; + -fx-font-weight: bold; +} + +.assets-mutation-section-title { + -fx-text-fill: #a8c8e9; + -fx-font-size: 11px; + -fx-font-weight: bold; +} + +.assets-mutation-change { + -fx-text-fill: #dce6f1; + -fx-font-size: 12px; +} + +.assets-mutation-message-blocker { + -fx-text-fill: #ffb2b6; + -fx-font-size: 12px; +} + +.assets-mutation-message-warning { + -fx-text-fill: #ffd486; + -fx-font-size: 12px; +} + +.assets-mutation-message-safe-fix { + -fx-text-fill: #bfe9cf; + -fx-font-size: 12px; +} + +.assets-mutation-cancel { + -fx-background-color: #1b2430; + -fx-text-fill: #d6e0ea; + -fx-border-color: #314154; + -fx-border-radius: 10; + -fx-background-radius: 10; +} + +.assets-mutation-apply { + -fx-background-color: #27507a; + -fx-text-fill: #f7fbff; + -fx-border-color: #5ea0de; + -fx-border-radius: 10; + -fx-background-radius: 10; +} + .assets-workspace-logs-pane { -fx-collapsible: true; } diff --git a/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java b/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java index c4a70a1b..7fc9b6a6 100644 --- a/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java +++ b/prometeu-studio/src/test/java/p/studio/controls/shell/StudioActivityEventMapperTest.java @@ -1,10 +1,14 @@ package p.studio.controls.shell; import org.junit.jupiter.api.Test; +import p.studio.events.StudioAssetsMutationAppliedEvent; +import p.studio.events.StudioAssetsMutationFailedEvent; +import p.studio.events.StudioAssetsMutationPreviewReadyEvent; import p.studio.events.StudioAssetsWorkspaceRefreshFailedEvent; import p.studio.events.StudioAssetsWorkspaceRefreshedEvent; import p.studio.events.StudioProjectOpenedEvent; import p.studio.projects.ProjectReference; +import p.studio.workspaces.assets.AssetWorkspaceAction; import java.nio.file.Path; @@ -45,6 +49,40 @@ final class StudioActivityEventMapperTest { assertEquals("Refresh failed", entry.message()); } + @Test + void mapsMutationPreviewReadyToInfoEntry() { + final StudioActivityEntry entry = StudioActivityEventMapper + .map(new StudioAssetsMutationPreviewReadyEvent(project(), AssetWorkspaceAction.QUARANTINE, 1)) + .orElseThrow(); + + assertEquals("Assets", entry.source()); + assertEquals(StudioActivityEntrySeverity.INFO, entry.severity()); + assertEquals("Preview ready: quarantine", entry.message()); + } + + @Test + void mapsMutationAppliedToSuccessEntry() { + final StudioActivityEntry entry = StudioActivityEventMapper + .map(new StudioAssetsMutationAppliedEvent(project(), AssetWorkspaceAction.RELOCATE, 1)) + .orElseThrow(); + + assertEquals("Assets", entry.source()); + assertEquals(StudioActivityEntrySeverity.SUCCESS, entry.severity()); + assertEquals("Action applied: relocate", entry.message()); + } + + @Test + void mapsMutationFailureToStickyErrorEntry() { + final StudioActivityEntry entry = StudioActivityEventMapper + .map(new StudioAssetsMutationFailedEvent(project(), AssetWorkspaceAction.REMOVE, "Apply failed")) + .orElseThrow(); + + assertEquals("Assets", entry.source()); + assertEquals(StudioActivityEntrySeverity.ERROR, entry.severity()); + assertTrue(entry.sticky()); + assertEquals("Apply failed", entry.message()); + } + private ProjectReference project() { return new ProjectReference("Main", "1.0.0", "pbs", 1, Path.of("/tmp/main")); } diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilderTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilderTest.java index e1f54774..c4f5429e 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilderTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceActionSetBuilderTest.java @@ -21,11 +21,17 @@ final class AssetWorkspaceActionSetBuilderTest { false)); assertEquals(List.of(AssetWorkspaceAction.DOCTOR, AssetWorkspaceAction.BUILD), actionSet.primaryActions()); - assertEquals(List.of(AssetWorkspaceAction.FORGET, AssetWorkspaceAction.REMOVE), actionSet.sensitiveActions()); + assertEquals( + List.of( + AssetWorkspaceAction.FORGET, + AssetWorkspaceAction.QUARANTINE, + AssetWorkspaceAction.RELOCATE, + AssetWorkspaceAction.REMOVE), + actionSet.sensitiveActions()); } @Test - void orphanAssetsExposeAdoptAndRegisterOnly() { + void orphanAssetsExposeAdoptRegisterAndSensitiveRelocationFlows() { final AssetWorkspaceActionSet actionSet = AssetWorkspaceActionSetBuilder.forAsset(new AssetWorkspaceAssetSummary( new AssetWorkspaceSelectionKey.OrphanAsset(Path.of("/tmp/assets/ui_sounds")), "ui_sounds", @@ -37,6 +43,6 @@ final class AssetWorkspaceActionSetBuilderTest { false)); assertEquals(List.of(AssetWorkspaceAction.ADOPT, AssetWorkspaceAction.REGISTER), actionSet.primaryActions()); - assertEquals(List.of(), actionSet.sensitiveActions()); + assertEquals(List.of(AssetWorkspaceAction.QUARANTINE, AssetWorkspaceAction.RELOCATE), actionSet.sensitiveActions()); } } diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModelTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModelTest.java new file mode 100644 index 00000000..1be2e720 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/AssetWorkspaceMutationImpactViewModelTest.java @@ -0,0 +1,41 @@ +package p.studio.workspaces.assets; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class AssetWorkspaceMutationImpactViewModelTest { + @Test + void splitsRegistryAndWorkspaceChangesForRendering() { + final AssetWorkspaceAssetSummary asset = new AssetWorkspaceAssetSummary( + new AssetWorkspaceSelectionKey.ManagedAsset(1), + "ui_atlas", + AssetWorkspaceAssetState.MANAGED, + 1, + "image_bank", + Path.of("/tmp/assets/ui/atlas"), + false, + false); + final AssetWorkspaceMutationPreview preview = new AssetWorkspaceMutationPreview( + AssetWorkspaceAction.RELOCATE, + asset, + List.of(), + List.of(), + List.of(), + List.of( + new AssetWorkspaceMutationChange(AssetWorkspaceMutationChangeScope.REGISTRY, "UPDATE", "ui/atlas -> ui/atlas-relocated"), + new AssetWorkspaceMutationChange(AssetWorkspaceMutationChangeScope.WORKSPACE, "MOVE", "ui/atlas -> ui/atlas-relocated")), + true, + Path.of("/tmp/assets/ui/atlas-relocated")); + + final AssetWorkspaceMutationImpactViewModel viewModel = AssetWorkspaceMutationImpactViewModel.from(preview); + + assertEquals(1, viewModel.registryChanges().size()); + assertEquals(1, viewModel.workspaceChanges().size()); + assertEquals("UPDATE", viewModel.registryChanges().getFirst().verb()); + assertEquals("MOVE", viewModel.workspaceChanges().getFirst().verb()); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationServiceTest.java new file mode 100644 index 00000000..3e80530e --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/FileSystemAssetWorkspaceMutationServiceTest.java @@ -0,0 +1,144 @@ +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.*; + +final class FileSystemAssetWorkspaceMutationServiceTest { + @TempDir + Path tempDir; + + @Test + void previewQuarantineForManagedAssetShowsRegistryAndWorkspaceImpact() throws Exception { + final Path projectRoot = createManagedAssetProject(); + final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); + final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot); + + final AssetWorkspaceMutationPreview preview = service.preview(project("Main", projectRoot), asset, AssetWorkspaceAction.QUARANTINE); + + assertTrue(preview.canApply()); + assertFalse(preview.highRisk()); + assertNotNull(preview.targetAssetRoot()); + assertEquals(1, preview.changes().stream().filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.REGISTRY).count()); + assertEquals(1, preview.changes().stream().filter(change -> change.scope() == AssetWorkspaceMutationChangeScope.WORKSPACE).count()); + } + + @Test + void applyQuarantineMovesAssetAndRemovesRegistryEntry() throws Exception { + final Path projectRoot = createManagedAssetProject(); + final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); + final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot); + final ProjectReference project = project("Main", projectRoot); + + final AssetWorkspaceMutationPreview preview = service.preview(project, asset, AssetWorkspaceAction.QUARANTINE); + service.apply(project, preview); + + assertFalse(Files.exists(asset.assetRoot())); + assertTrue(Files.isDirectory(preview.targetAssetRoot())); + final String registryJson = Files.readString(projectRoot.resolve("assets/.prometeu/index.json")); + assertFalse(registryJson.contains("\"root\" : \"ui/atlas\"")); + } + + @Test + void previewRelocateForManagedAssetIsHighRiskAndPreservesIdentityContract() throws Exception { + final Path projectRoot = createManagedAssetProject(); + final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); + final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot); + + final AssetWorkspaceMutationPreview preview = service.preview(project("Main", projectRoot), asset, AssetWorkspaceAction.RELOCATE); + + assertTrue(preview.canApply()); + assertTrue(preview.highRisk()); + assertNotNull(preview.targetAssetRoot()); + assertTrue(preview.changes().stream().anyMatch(change -> + change.scope() == AssetWorkspaceMutationChangeScope.REGISTRY && change.verb().equals("UPDATE"))); + assertTrue(preview.changes().stream().anyMatch(change -> + change.scope() == AssetWorkspaceMutationChangeScope.WORKSPACE && change.verb().equals("MOVE"))); + } + + @Test + void applyRelocateMovesAssetAndUpdatesRegistryRoot() throws Exception { + final Path projectRoot = createManagedAssetProject(); + final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); + final AssetWorkspaceAssetSummary asset = managedAsset(projectRoot); + final ProjectReference project = project("Main", projectRoot); + + final AssetWorkspaceMutationPreview preview = service.preview(project, asset, AssetWorkspaceAction.RELOCATE); + service.apply(project, preview); + + assertFalse(Files.exists(asset.assetRoot())); + assertTrue(Files.isDirectory(preview.targetAssetRoot())); + final String registryJson = Files.readString(projectRoot.resolve("assets/.prometeu/index.json")); + assertTrue(registryJson.contains("\"asset_id\" : 1")); + assertTrue(registryJson.contains(preview.targetAssetRoot().getFileName().toString())); + } + + @Test + void previewWithMissingAssetRootCreatesBlockerAndDisablesApply() { + final FileSystemAssetWorkspaceMutationService service = new FileSystemAssetWorkspaceMutationService(); + final Path projectRoot = tempDir.resolve("main"); + final AssetWorkspaceAssetSummary asset = new AssetWorkspaceAssetSummary( + new AssetWorkspaceSelectionKey.OrphanAsset(projectRoot.resolve("assets/missing")), + "missing_asset", + AssetWorkspaceAssetState.ORPHAN, + null, + "unknown", + projectRoot.resolve("assets/missing"), + false, + false); + + final AssetWorkspaceMutationPreview preview = service.preview(project("Main", projectRoot), asset, AssetWorkspaceAction.QUARANTINE); + + assertFalse(preview.canApply()); + assertFalse(preview.blockers().isEmpty()); + } + + private Path createManagedAssetProject() throws Exception { + final Path projectRoot = tempDir.resolve("main"); + final Path assetRoot = projectRoot.resolve("assets/ui/atlas"); + Files.createDirectories(assetRoot); + Files.createDirectories(projectRoot.resolve("assets/.prometeu")); + Files.writeString(assetRoot.resolve("asset.json"), """ + { + "name": "ui_atlas", + "type": "image_bank", + "preload": { "enabled": true } + } + """); + Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """ + { + "schema_version": 1, + "next_asset_id": 2, + "assets": [ + { + "asset_id": 1, + "asset_uuid": "uuid-1", + "root": "ui/atlas" + } + ] + } + """); + return projectRoot; + } + + private AssetWorkspaceAssetSummary managedAsset(Path projectRoot) { + return new AssetWorkspaceAssetSummary( + new AssetWorkspaceSelectionKey.ManagedAsset(1), + "ui_atlas", + AssetWorkspaceAssetState.MANAGED, + 1, + "image_bank", + projectRoot.resolve("assets/ui/atlas"), + true, + false); + } + + private ProjectReference project(String name, Path root) { + return new ProjectReference(name, "1.0.0", "pbs", 1, root); + } +} diff --git a/test-projects/tototo/prometeu.json b/test-projects/tototo/prometeu.json deleted file mode 100644 index 96c0e295..00000000 --- a/test-projects/tototo/prometeu.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "tototo", - "version": "1.0.0", - "language": "pbs", - "stdlib": "12346789", - "dependencies": [ - ] -} diff --git a/test-projects/umbelivable/prometeu.json b/test-projects/umbelivable/prometeu.json deleted file mode 100644 index c599bcf5..00000000 --- a/test-projects/umbelivable/prometeu.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "umbelivable", - "version": "1.0.0", - "language": "pbs", - "stdlib": "1", - "dependencies": [ - ] -}