assets workspace 5e

This commit is contained in:
bQUARKz 2026-03-11 17:16:13 +00:00
parent f7e0a4043d
commit edf3e6d3ec
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
23 changed files with 981 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,8 @@ public enum AssetWorkspaceAction {
BUILD,
ADOPT,
REGISTER,
QUARANTINE,
RELOCATE,
FORGET,
REMOVE
}

View File

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

View File

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

View File

@ -0,0 +1,6 @@
package p.studio.workspaces.assets;
public enum AssetWorkspaceMutationChangeScope {
REGISTRY,
WORKSPACE
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +0,0 @@
{
"name": "tototo",
"version": "1.0.0",
"language": "pbs",
"stdlib": "12346789",
"dependencies": [
]
}

View File

@ -1,8 +0,0 @@
{
"name": "umbelivable",
"version": "1.0.0",
"language": "pbs",
"stdlib": "1",
"dependencies": [
]
}