assets workspace 5e
This commit is contained in:
parent
f7e0a4043d
commit
edf3e6d3ec
@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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"),
|
||||
|
||||
@ -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<AssetWorkspaceMutationChange> 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<String> 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)
|
||||
|
||||
@ -5,6 +5,8 @@ public enum AssetWorkspaceAction {
|
||||
BUILD,
|
||||
ADOPT,
|
||||
REGISTER,
|
||||
QUARANTINE,
|
||||
RELOCATE,
|
||||
FORGET,
|
||||
REMOVE
|
||||
}
|
||||
|
||||
@ -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));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package p.studio.workspaces.assets;
|
||||
|
||||
public enum AssetWorkspaceMutationChangeScope {
|
||||
REGISTRY,
|
||||
WORKSPACE
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package p.studio.workspaces.assets;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public record AssetWorkspaceMutationImpactViewModel(
|
||||
List<AssetWorkspaceMutationChange> registryChanges,
|
||||
List<AssetWorkspaceMutationChange> 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());
|
||||
}
|
||||
}
|
||||
@ -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<String> blockers,
|
||||
List<String> warnings,
|
||||
List<String> safeFixes,
|
||||
List<AssetWorkspaceMutationChange> 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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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<String> blockers = new ArrayList<>();
|
||||
final List<String> warnings = new ArrayList<>();
|
||||
final List<String> safeFixes = new ArrayList<>();
|
||||
final List<AssetWorkspaceMutationChange> 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<Path> 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<RegistryEntry> 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "tototo",
|
||||
"version": "1.0.0",
|
||||
"language": "pbs",
|
||||
"stdlib": "12346789",
|
||||
"dependencies": [
|
||||
]
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "umbelivable",
|
||||
"version": "1.0.0",
|
||||
"language": "pbs",
|
||||
"stdlib": "1",
|
||||
"dependencies": [
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user