From 165be7612839ae4075b2ea936bfe04606c099e6b Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Mon, 16 Mar 2026 08:14:55 +0000 Subject: [PATCH] implements PR-022 --- ...firmation-and-fs-first-manifest-removal.md | 77 ++++++++++++++++ docs/packer/pull-requests/README.md | 3 +- .../java/p/packer/PackerWorkspaceService.java | 2 + .../p/packer/messages/DeleteAssetRequest.java | 13 +++ .../p/packer/messages/DeleteAssetResult.java | 25 ++++++ .../p/packer/messages/assets/AssetAction.java | 3 +- .../models/PackerDeleteAssetEvaluation.java | 20 +++++ .../FileSystemPackerWorkspaceService.java | 82 +++++++++++++++++ .../PackerAssetActionReadService.java | 47 +++++++++- .../services/PackerRuntimePatchService.java | 11 +++ .../FileSystemPackerWorkspaceServiceTest.java | 90 ++++++++++++++++--- .../java/p/studio/utilities/i18n/I18n.java | 7 +- .../assets/details/AssetDetailsControl.java | 59 +++++++++++- .../assets/details/AssetDetailsUiSupport.java | 1 + .../assets/wizards/DeleteAssetDialog.java | 84 +++++++++++++++++ .../main/resources/i18n/messages.properties | 6 ++ 16 files changed, 508 insertions(+), 22 deletions(-) create mode 100644 docs/packer/pull-requests/PR-22-delete-asset-action-confirmation-and-fs-first-manifest-removal.md create mode 100644 prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/DeleteAssetRequest.java create mode 100644 prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/DeleteAssetResult.java create mode 100644 prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerDeleteAssetEvaluation.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/wizards/DeleteAssetDialog.java diff --git a/docs/packer/pull-requests/PR-22-delete-asset-action-confirmation-and-fs-first-manifest-removal.md b/docs/packer/pull-requests/PR-22-delete-asset-action-confirmation-and-fs-first-manifest-removal.md new file mode 100644 index 00000000..72ae1ea1 --- /dev/null +++ b/docs/packer/pull-requests/PR-22-delete-asset-action-confirmation-and-fs-first-manifest-removal.md @@ -0,0 +1,77 @@ +# PR-22 Delete Asset Action Confirmation and Fs-First Manifest Removal + +Domain Owner: `docs/packer` +Cross-Domain Impact: `docs/studio` + +## Briefing + +The action capability contract introduced in `PR-20` needs its next real delivery beyond `REGISTER`. + +`DELETE` must remove the asset from packer control without deleting the asset directory or its remaining files. The operation is filesystem-first: delete `asset.json`, update `index.json` when needed, and then apply a point snapshot update in memory. + +Studio must require explicit confirmation before calling this write path. + +## Objective + +Deliver `AssetAction.DELETE` end to end with packer-owned capability resolution, Studio confirmation modal, filesystem-first manifest removal, and point runtime snapshot update. + +## Dependencies + +- [`./PR-20-asset-action-capabilities-and-register-first-delivery.md`](./PR-20-asset-action-capabilities-and-register-first-delivery.md) +- [`./PR-21-point-in-memory-snapshot-updates-after-write-commit.md`](./PR-21-point-in-memory-snapshot-updates-after-write-commit.md) + +## Scope + +- extend the public action contract with `DELETE` +- expose `DELETE` capability from the packer for assets that currently own `asset.json` +- add a packer write path that deletes only `asset.json` +- remove any registered entry from `index.json` +- keep the asset directory and its non-manifest files on disk +- patch the loaded runtime snapshot in memory after successful delete +- add a Studio modal that requires typing the asset name before confirming deletion + +## Non-Goals + +- no recursive directory deletion +- no deletion of companion files or arbitrary asset contents +- no frontend-local action capability rules +- no bulk delete + +## Execution Method + +1. Extend the action enum and packer API with `DELETE` and its write message/response. +2. Add packer capability resolution for `DELETE` based on `asset.json` presence, independent from declaration validity. +3. Implement `deleteAsset` in the packer write lane. +4. Make the write path: + - resolve the asset + - delete `asset.json` + - remove the registry entry when the asset is registered + - keep the asset directory and any remaining files untouched + - patch the in-memory snapshot by removing the asset from runtime view +5. Add a Studio confirmation modal that requires the user to type the asset name exactly. +6. On success, let Studio refresh and clear the current selection. + +## Acceptance Criteria + +- `DELETE` is exposed through the packer action capability contract +- Studio renders `DELETE` only from packer-provided capabilities +- Studio requires asset-name confirmation before executing `DELETE` +- `DELETE` removes only `asset.json` +- registered assets are also removed from `index.json` +- the asset directory and remaining files stay on disk +- the runtime snapshot is updated in memory without whole-project reload in the normal path + +## Validation + +- packer tests for `DELETE` capability visibility +- packer tests for deleting registered and unregistered assets +- packer tests proving directory contents remain on disk after delete +- Studio compile/test validation for the confirmation modal and action wiring + +## Affected Artifacts + +- `prometeu-packer/prometeu-packer-api/src/main/java/p/packer/**` +- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/**` +- `prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/**` +- `prometeu-studio/src/main/java/p/studio/**` +- `prometeu-studio/src/main/resources/**` diff --git a/docs/packer/pull-requests/README.md b/docs/packer/pull-requests/README.md index c708f93d..db752620 100644 --- a/docs/packer/pull-requests/README.md +++ b/docs/packer/pull-requests/README.md @@ -81,6 +81,7 @@ The current production track for the standalone `prometeu-packer` project is: 19. [`PR-19-api-surface-audit-model-separation-and-public-read-dtos.md`](./PR-19-api-surface-audit-model-separation-and-public-read-dtos.md) 20. [`PR-20-asset-action-capabilities-and-register-first-delivery.md`](./PR-20-asset-action-capabilities-and-register-first-delivery.md) 21. [`PR-21-point-in-memory-snapshot-updates-after-write-commit.md`](./PR-21-point-in-memory-snapshot-updates-after-write-commit.md) +22. [`PR-22-delete-asset-action-confirmation-and-fs-first-manifest-removal.md`](./PR-22-delete-asset-action-confirmation-and-fs-first-manifest-removal.md) Current wave discipline from `PR-11` onward: @@ -91,4 +92,4 @@ Current wave discipline from `PR-11` onward: Recommended dependency chain: -`PR-01 -> PR-02 -> PR-03 -> PR-04 -> PR-05 -> PR-06 -> PR-07 -> PR-08 -> PR-09 -> PR-10 -> PR-11 -> PR-12 -> PR-13 -> PR-14 -> PR-15 -> PR-16 -> PR-17 -> PR-18 -> PR-19 -> PR-20 -> PR-21` +`PR-01 -> PR-02 -> PR-03 -> PR-04 -> PR-05 -> PR-06 -> PR-07 -> PR-08 -> PR-09 -> PR-10 -> PR-11 -> PR-12 -> PR-13 -> PR-14 -> PR-15 -> PR-16 -> PR-17 -> PR-18 -> PR-19 -> PR-20 -> PR-21 -> PR-22` diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/PackerWorkspaceService.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/PackerWorkspaceService.java index 62daa747..fa47ec31 100644 --- a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/PackerWorkspaceService.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/PackerWorkspaceService.java @@ -14,4 +14,6 @@ public interface PackerWorkspaceService { CreateAssetResult createAsset(CreateAssetRequest request); RegisterAssetResult registerAsset(RegisterAssetRequest request); + + DeleteAssetResult deleteAsset(DeleteAssetRequest request); } diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/DeleteAssetRequest.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/DeleteAssetRequest.java new file mode 100644 index 00000000..3fb7858f --- /dev/null +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/DeleteAssetRequest.java @@ -0,0 +1,13 @@ +package p.packer.messages; + +import java.util.Objects; + +public record DeleteAssetRequest( + PackerProjectContext project, + AssetReference assetReference) { + + public DeleteAssetRequest { + Objects.requireNonNull(project, "project"); + Objects.requireNonNull(assetReference, "assetReference"); + } +} diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/DeleteAssetResult.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/DeleteAssetResult.java new file mode 100644 index 00000000..f86bff5e --- /dev/null +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/DeleteAssetResult.java @@ -0,0 +1,25 @@ +package p.packer.messages; + +import java.nio.file.Path; +import java.util.Objects; + +public record DeleteAssetResult( + PackerOperationStatus status, + String summary, + Path assetRoot, + Path manifestPath) { + + public DeleteAssetResult { + Objects.requireNonNull(status, "status"); + summary = Objects.requireNonNull(summary, "summary").trim(); + if (summary.isBlank()) { + throw new IllegalArgumentException("summary must not be blank"); + } + if (assetRoot != null) { + assetRoot = assetRoot.toAbsolutePath().normalize(); + } + if (manifestPath != null) { + manifestPath = manifestPath.toAbsolutePath().normalize(); + } + } +} diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/assets/AssetAction.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/assets/AssetAction.java index 6fd38136..1e04d8cc 100644 --- a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/assets/AssetAction.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/assets/AssetAction.java @@ -1,5 +1,6 @@ package p.packer.messages.assets; public enum AssetAction { - REGISTER + REGISTER, + DELETE } diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerDeleteAssetEvaluation.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerDeleteAssetEvaluation.java new file mode 100644 index 00000000..242a893c --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerDeleteAssetEvaluation.java @@ -0,0 +1,20 @@ +package p.packer.models; + +import java.util.List; +import java.util.Objects; + +public record PackerDeleteAssetEvaluation( + PackerResolvedAssetReference resolved, + List diagnostics, + boolean canDelete, + String reason) { + + public PackerDeleteAssetEvaluation { + Objects.requireNonNull(resolved, "resolved"); + diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); + reason = reason == null ? null : reason.trim(); + if (reason != null && reason.isBlank()) { + reason = null; + } + } +} diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java index 4d816471..7c36a20a 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java @@ -17,6 +17,8 @@ import p.packer.events.PackerEventSink; import p.packer.events.PackerProgress; import p.packer.messages.CreateAssetRequest; import p.packer.messages.CreateAssetResult; +import p.packer.messages.DeleteAssetRequest; +import p.packer.messages.DeleteAssetResult; import p.packer.messages.GetAssetDetailsRequest; import p.packer.messages.GetAssetDetailsResult; import p.packer.messages.InitWorkspaceRequest; @@ -27,6 +29,7 @@ import p.packer.messages.RegisterAssetRequest; import p.packer.messages.RegisterAssetResult; import p.packer.PackerWorkspaceService; import p.packer.models.PackerAssetDeclarationParseResult; +import p.packer.models.PackerDeleteAssetEvaluation; import p.packer.models.PackerAssetIdentity; import p.packer.models.PackerRegisterAssetEvaluation; import p.packer.models.PackerAssetSummary; @@ -166,6 +169,14 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe return writeCoordinator.execute(project, () -> registerAssetInWriteLane(safeRequest, events)); } + @Override + public DeleteAssetResult deleteAsset(DeleteAssetRequest request) { + final DeleteAssetRequest safeRequest = Objects.requireNonNull(request, "request"); + final PackerProjectContext project = safeRequest.project(); + final PackerOperationEventEmitter events = new PackerOperationEventEmitter(project, eventSink); + return writeCoordinator.execute(project, () -> deleteAssetInWriteLane(safeRequest, events)); + } + private CreateAssetResult createAssetInWriteLane( CreateAssetRequest request, PackerOperationEventEmitter events) { @@ -295,6 +306,50 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe } } + private DeleteAssetResult deleteAssetInWriteLane( + DeleteAssetRequest request, + PackerOperationEventEmitter events) { + final PackerProjectContext project = request.project(); + workspaceFoundation.initWorkspace(new InitWorkspaceRequest(project)); + final PackerRuntimeSnapshot snapshot = runtimeRegistry.getOrLoad(project).snapshot(); + final PackerDeleteAssetEvaluation evaluation = actionReadService.evaluateDelete(snapshot, project, request.assetReference()); + final Path assetRoot = evaluation.resolved().assetRoot(); + final Path manifestPath = assetRoot.resolve("asset.json"); + final String relativeAssetRoot = relativeAssetRoot(project, assetRoot); + if (!evaluation.canDelete()) { + final String summary = Objects.requireNonNullElse(evaluation.reason(), "Asset cannot be deleted."); + return deleteFailureResult(events, summary, assetRoot, manifestPath, List.of(relativeAssetRoot)); + } + + try { + final PackerRegistryState registry = workspaceFoundation.loadRegistry(project); + final PackerRegistryState updatedRegistry = removeRegistryEntry(registry, evaluation.resolved().registryEntry().orElse(null)); + Files.deleteIfExists(manifestPath); + if (!updatedRegistry.equals(registry)) { + workspaceFoundation.saveRegistry(project, updatedRegistry); + } + runtimeRegistry.update(project, (currentSnapshot, generation) -> runtimePatchService.afterDeleteAsset( + currentSnapshot, + generation, + updatedRegistry, + assetRoot)); + final DeleteAssetResult result = new DeleteAssetResult( + PackerOperationStatus.SUCCESS, + "Asset deleted: " + relativeAssetRoot, + assetRoot, + manifestPath); + events.emit(PackerEventKind.ACTION_APPLIED, result.summary(), List.of(relativeAssetRoot)); + return result; + } catch (IOException exception) { + return deleteFailureResult( + events, + "Unable to delete asset: " + exception.getMessage(), + assetRoot, + manifestPath, + List.of(relativeAssetRoot)); + } + } + private CreateAssetResult failureResult( PackerOperationEventEmitter events, String summary, @@ -327,6 +382,21 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe return result; } + private DeleteAssetResult deleteFailureResult( + PackerOperationEventEmitter events, + String summary, + Path assetRoot, + Path manifestPath, + List affectedAssets) { + final DeleteAssetResult result = new DeleteAssetResult( + PackerOperationStatus.FAILED, + summary, + assetRoot, + manifestPath); + events.emit(PackerEventKind.ACTION_FAILED, result.summary(), affectedAssets); + return result; + } + private void writeManifest(Path manifestPath, CreateAssetRequest request, String assetUuid) throws IOException { final Map manifest = new LinkedHashMap<>(); manifest.put("schema_version", 1); @@ -420,4 +490,16 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe private String relativeAssetRoot(PackerProjectContext project, Path assetRoot) { return PackerWorkspacePaths.relativeAssetRoot(project, assetRoot).replace('\\', '/'); } + + private PackerRegistryState removeRegistryEntry( + PackerRegistryState registry, + PackerRegistryEntry entry) { + if (entry == null) { + return registry; + } + final List updatedEntries = registry.assets().stream() + .filter(candidate -> candidate.assetId() != entry.assetId()) + .toList(); + return registry.withAssets(updatedEntries, registry.nextAssetId()); + } } diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetActionReadService.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetActionReadService.java index 29c58397..44c89b48 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetActionReadService.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetActionReadService.java @@ -12,6 +12,7 @@ import p.packer.messages.diagnostics.PackerDiagnosticSeverity; import p.packer.models.PackerAssetActionAvailability; import p.packer.models.PackerAssetDeclaration; import p.packer.models.PackerAssetDeclarationParseResult; +import p.packer.models.PackerDeleteAssetEvaluation; import p.packer.models.PackerDiagnostic; import p.packer.models.PackerRegisterAssetEvaluation; import p.packer.models.PackerRegistryEntry; @@ -43,6 +44,7 @@ public final class PackerAssetActionReadService { final PackerProjectContext project = Objects.requireNonNull(request, "request").project(); final PackerRuntimeSnapshot snapshot = runtimeRegistry.getOrLoad(project).snapshot(); final PackerRegisterAssetEvaluation registerEvaluation = evaluateRegister(snapshot, project, request.assetReference()); + final PackerDeleteAssetEvaluation deleteEvaluation = evaluateDelete(snapshot, project, request.assetReference()); final List actions = new ArrayList<>(); if (registerEvaluation.resolved().registryEntry().isEmpty()) { actions.add(new PackerAssetActionAvailability( @@ -51,11 +53,18 @@ public final class PackerAssetActionReadService { true, registerEvaluation.reason())); } + if (deleteEvaluation.resolved().runtimeAsset().isPresent()) { + actions.add(new PackerAssetActionAvailability( + AssetAction.DELETE, + deleteEvaluation.canDelete(), + true, + deleteEvaluation.reason())); + } final PackerOperationStatus status; - if (registerEvaluation.resolved().runtimeAsset().isEmpty()) { + if (registerEvaluation.resolved().runtimeAsset().isEmpty() && deleteEvaluation.resolved().runtimeAsset().isEmpty()) { status = PackerOperationStatus.FAILED; - } else if (registerEvaluation.diagnostics().stream().anyMatch(PackerDiagnostic::blocking)) { + } else if (combinedDiagnostics(registerEvaluation.diagnostics(), deleteEvaluation.diagnostics()).stream().anyMatch(PackerDiagnostic::blocking)) { status = PackerOperationStatus.PARTIAL; } else { status = PackerOperationStatus.SUCCESS; @@ -64,7 +73,7 @@ public final class PackerAssetActionReadService { status, "Asset action capabilities resolved from runtime snapshot.", PackerReadMessageMapper.toAssetActionAvailabilityDTOs(actions), - PackerReadMessageMapper.toDiagnosticDTOs(registerEvaluation.diagnostics())); + PackerReadMessageMapper.toDiagnosticDTOs(combinedDiagnostics(registerEvaluation.diagnostics(), deleteEvaluation.diagnostics()))); } public PackerRegisterAssetEvaluation evaluateRegister( @@ -120,6 +129,30 @@ public final class PackerAssetActionReadService { return new PackerRegisterAssetEvaluation(resolved, diagnostics, true, null); } + public PackerDeleteAssetEvaluation evaluateDelete( + PackerRuntimeSnapshot snapshot, + PackerProjectContext project, + AssetReference assetReference) { + final PackerResolvedAssetReference resolved = assetReferenceResolver.resolve(project, snapshot, assetReference); + final List diagnostics = new ArrayList<>(resolved.diagnostics()); + if (resolved.runtimeAsset().isEmpty()) { + if (resolved.registryEntry().isPresent()) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "asset.json was not found for the requested asset root.", + resolved.assetRoot().resolve("asset.json"), + true)); + } + return new PackerDeleteAssetEvaluation( + resolved, + diagnostics, + false, + "asset.json was not found for the requested asset root."); + } + return new PackerDeleteAssetEvaluation(resolved, diagnostics, true, null); + } + private String firstBlockingReason(List diagnostics, String fallback) { return diagnostics.stream() .filter(PackerDiagnostic::blocking) @@ -128,4 +161,12 @@ public final class PackerAssetActionReadService { .findFirst() .orElse(fallback); } + + private List combinedDiagnostics( + List left, + List right) { + final List combined = new ArrayList<>(left); + combined.addAll(right); + return combined; + } } diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRuntimePatchService.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRuntimePatchService.java index 586d9f7c..77748eb2 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRuntimePatchService.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerRuntimePatchService.java @@ -65,4 +65,15 @@ public final class PackerRuntimePatchService { } return new PackerRuntimeSnapshot(generation, updatedRegistry, updatedAssets); } + + public PackerRuntimeSnapshot afterDeleteAsset( + PackerRuntimeSnapshot snapshot, + long generation, + PackerRegistryState updatedRegistry, + Path assetRoot) { + final List updatedAssets = snapshot.assets().stream() + .filter(asset -> !asset.assetRoot().equals(assetRoot.toAbsolutePath().normalize())) + .toList(); + return new PackerRuntimeSnapshot(generation, updatedRegistry, updatedAssets); + } } diff --git a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java index f4b6e6a1..a3c962eb 100644 --- a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java @@ -14,6 +14,8 @@ import p.packer.events.PackerEvent; import p.packer.events.PackerEventKind; import p.packer.messages.CreateAssetRequest; import p.packer.messages.CreateAssetResult; +import p.packer.messages.DeleteAssetRequest; +import p.packer.messages.DeleteAssetResult; import p.packer.messages.GetAssetActionsRequest; import p.packer.messages.GetAssetDetailsRequest; import p.packer.messages.ListAssetsRequest; @@ -183,11 +185,15 @@ final class FileSystemPackerWorkspaceServiceTest { AssetReference.forRelativeAssetRoot("orphans/ui_sounds"))); assertEquals(PackerOperationStatus.SUCCESS, result.status()); - assertEquals(1, result.actions().size()); - assertEquals(AssetAction.REGISTER, result.actions().getFirst().action()); - assertTrue(result.actions().getFirst().visible()); - assertTrue(result.actions().getFirst().enabled()); - assertNull(result.actions().getFirst().reason()); + assertEquals(2, result.actions().size()); + assertEquals(AssetAction.REGISTER, result.actions().get(0).action()); + assertTrue(result.actions().get(0).visible()); + assertTrue(result.actions().get(0).enabled()); + assertNull(result.actions().get(0).reason()); + assertEquals(AssetAction.DELETE, result.actions().get(1).action()); + assertTrue(result.actions().get(1).visible()); + assertTrue(result.actions().get(1).enabled()); + assertNull(result.actions().get(1).reason()); } @Test @@ -200,7 +206,9 @@ final class FileSystemPackerWorkspaceServiceTest { AssetReference.forAssetId(1))); assertEquals(PackerOperationStatus.SUCCESS, result.status()); - assertTrue(result.actions().isEmpty()); + assertEquals(1, result.actions().size()); + assertEquals(AssetAction.DELETE, result.actions().getFirst().action()); + assertTrue(result.actions().getFirst().enabled()); } @Test @@ -246,7 +254,8 @@ final class FileSystemPackerWorkspaceServiceTest { final var actionsResult = service.getAssetActions(new GetAssetActionsRequest( project(projectRoot), registerResult.assetReference())); - assertTrue(actionsResult.actions().isEmpty()); + assertEquals(1, actionsResult.actions().size()); + assertEquals(AssetAction.DELETE, actionsResult.actions().getFirst().action()); assertEquals(1, loader.loadCount()); } @@ -260,9 +269,12 @@ final class FileSystemPackerWorkspaceServiceTest { AssetReference.forRelativeAssetRoot("bad"))); assertEquals(PackerOperationStatus.PARTIAL, result.status()); - assertEquals(1, result.actions().size()); - assertFalse(result.actions().getFirst().enabled()); - assertNotNull(result.actions().getFirst().reason()); + assertEquals(2, result.actions().size()); + assertEquals(AssetAction.REGISTER, result.actions().get(0).action()); + assertFalse(result.actions().get(0).enabled()); + assertNotNull(result.actions().get(0).reason()); + assertEquals(AssetAction.DELETE, result.actions().get(1).action()); + assertTrue(result.actions().get(1).enabled()); } @Test @@ -322,9 +334,12 @@ final class FileSystemPackerWorkspaceServiceTest { final var actions = service.getAssetActions(new GetAssetActionsRequest( project(projectRoot), AssetReference.forRelativeAssetRoot("orphans/ui_clone"))); - assertEquals(1, actions.actions().size()); - assertFalse(actions.actions().getFirst().enabled()); - assertNotNull(actions.actions().getFirst().reason()); + assertEquals(2, actions.actions().size()); + assertEquals(AssetAction.REGISTER, actions.actions().get(0).action()); + assertFalse(actions.actions().get(0).enabled()); + assertNotNull(actions.actions().get(0).reason()); + assertEquals(AssetAction.DELETE, actions.actions().get(1).action()); + assertTrue(actions.actions().get(1).enabled()); final RegisterAssetResult registerResult = service.registerAsset(new RegisterAssetRequest( project(projectRoot), @@ -333,6 +348,55 @@ final class FileSystemPackerWorkspaceServiceTest { assertNull(registerResult.assetReference()); } + @Test + void deletesRegisteredAssetJsonWithoutDeletingDirectoryContents() throws Exception { + final Path projectRoot = tempDir.resolve("delete-registered"); + final FileSystemPackerWorkspaceService service = service(); + + final CreateAssetResult createResult = service.createAsset(new CreateAssetRequest( + project(projectRoot), + "ui/delete-me", + "delete_me", + AssetFamilyCatalog.IMAGE_BANK, + OutputFormatCatalog.TILES_INDEXED_V1, + OutputCodecCatalog.NONE, + false)); + final Path assetRoot = projectRoot.resolve("assets/ui/delete-me"); + Files.writeString(assetRoot.resolve("atlas.png"), "fixture"); + + final DeleteAssetResult deleteResult = service.deleteAsset(new DeleteAssetRequest( + project(projectRoot), + createResult.assetReference())); + + assertEquals(PackerOperationStatus.SUCCESS, deleteResult.status()); + assertTrue(Files.isDirectory(assetRoot)); + assertFalse(Files.exists(assetRoot.resolve("asset.json"))); + assertTrue(Files.isRegularFile(assetRoot.resolve("atlas.png"))); + + final var listResult = service.listAssets(new ListAssetsRequest(project(projectRoot))); + assertTrue(listResult.assets().isEmpty()); + } + + @Test + void deletesUnregisteredAssetJsonAndRemovesItFromSnapshot() throws Exception { + final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("delete-unregistered")); + final FileSystemPackerWorkspaceService service = service(); + final Path assetRoot = projectRoot.resolve("assets/orphans/ui_sounds"); + Files.writeString(assetRoot.resolve("notes.txt"), "keep me"); + + final DeleteAssetResult deleteResult = service.deleteAsset(new DeleteAssetRequest( + project(projectRoot), + AssetReference.forRelativeAssetRoot("orphans/ui_sounds"))); + + assertEquals(PackerOperationStatus.SUCCESS, deleteResult.status()); + assertTrue(Files.isDirectory(assetRoot)); + assertFalse(Files.exists(assetRoot.resolve("asset.json"))); + assertTrue(Files.isRegularFile(assetRoot.resolve("notes.txt"))); + + final var listResult = service.listAssets(new ListAssetsRequest(project(projectRoot))); + assertTrue(listResult.assets().isEmpty()); + } + @Test void rejectsUnsupportedFormatForSelectedFamily() { final Path projectRoot = tempDir.resolve("unsupported"); diff --git a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java index f32f27cb..805be9a1 100644 --- a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java +++ b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java @@ -102,10 +102,15 @@ public enum I18n { ASSETS_SECTION_ACTIONS("assets.section.actions"), ASSETS_ACTIONS_EMPTY("assets.actions.empty"), ASSETS_ACTION_REGISTER("assets.action.register"), + ASSETS_ACTION_DELETE("assets.action.delete"), ASSETS_ACTION_INCLUDE_IN_BUILD("assets.action.includeInBuild"), ASSETS_ACTION_EXCLUDE_FROM_BUILD("assets.action.excludeFromBuild"), ASSETS_ACTION_RELOCATE("assets.action.relocate"), - ASSETS_ACTION_REMOVE("assets.action.remove"), + ASSETS_DELETE_DIALOG_TITLE("assets.deleteDialog.title"), + ASSETS_DELETE_DIALOG_DESCRIPTION("assets.deleteDialog.description"), + ASSETS_DELETE_DIALOG_PROMPT("assets.deleteDialog.prompt"), + ASSETS_DELETE_DIALOG_NOTE("assets.deleteDialog.note"), + ASSETS_DELETE_DIALOG_CONFIRM("assets.deleteDialog.confirm"), ASSETS_MUTATION_PREVIEW_TITLE("assets.mutation.previewTitle"), ASSETS_MUTATION_SECTION_CHANGES("assets.mutation.section.changes"), ASSETS_MUTATION_SECTION_AFFECTED_ASSET("assets.mutation.section.affectedAsset"), diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java index 029001b6..ded0807f 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java @@ -11,6 +11,8 @@ import javafx.scene.layout.VBox; import p.packer.messages.AssetReference; import p.packer.dtos.PackerAssetActionAvailabilityDTO; import p.packer.dtos.PackerAssetDetailsDTO; +import p.packer.messages.DeleteAssetRequest; +import p.packer.messages.DeleteAssetResult; import p.packer.messages.GetAssetDetailsRequest; import p.packer.messages.GetAssetActionsRequest; import p.packer.messages.RegisterAssetRequest; @@ -29,6 +31,7 @@ import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState; import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent; import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent; import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceSelectionRequestedEvent; +import p.studio.workspaces.assets.wizards.DeleteAssetDialog; import p.studio.workspaces.framework.StudioEventAware; import p.studio.workspaces.framework.StudioEventBindings; @@ -302,12 +305,32 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware if (actionRunning || viewState.selectedAssetReference() == null) { return; } + switch (action.action()) { + case REGISTER -> { + actionRunning = true; + actionFeedbackMessage = null; + renderActions(); + Container.backgroundTasks().submit(() -> registerSelectedAsset(viewState.selectedAssetReference())); + } + case DELETE -> confirmDeleteAction(); + } + } + + private void confirmDeleteAction() { + if (viewState.selectedAssetDetails() == null || getScene() == null) { + return; + } + final boolean confirmed = DeleteAssetDialog.showAndWait( + getScene().getWindow(), + viewState.selectedAssetDetails().summary().assetName()); + if (!confirmed) { + return; + } actionRunning = true; actionFeedbackMessage = null; renderActions(); - switch (action.action()) { - case REGISTER -> Container.backgroundTasks().submit(() -> registerSelectedAsset(viewState.selectedAssetReference())); - } + final AssetReference assetReference = viewState.selectedAssetReference(); + Container.backgroundTasks().submit(() -> deleteSelectedAsset(assetReference)); } private void registerSelectedAsset(AssetReference assetReference) { @@ -341,6 +364,36 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware renderActions(); } + private void deleteSelectedAsset(AssetReference assetReference) { + try { + final DeleteAssetResult result = Container.packer().workspaceService().deleteAsset(new DeleteAssetRequest( + projectReference.toPackerProjectContext(), + assetReference)); + Platform.runLater(() -> applyDeleteResult(result)); + } catch (RuntimeException exception) { + Platform.runLater(() -> applyDeleteFailure(exception)); + } + } + + private void applyDeleteResult(DeleteAssetResult result) { + actionRunning = false; + if (result.status() == p.packer.messages.PackerOperationStatus.SUCCESS) { + actionFeedbackMessage = null; + workspaceBus.publish(new StudioAssetsRefreshRequestedEvent()); + return; + } + actionFeedbackMessage = Objects.requireNonNullElse(result.summary(), "Unable to delete asset."); + renderActions(); + } + + private void applyDeleteFailure(RuntimeException exception) { + actionRunning = false; + actionFeedbackMessage = exception.getMessage() == null || exception.getMessage().isBlank() + ? "Unable to delete asset." + : exception.getMessage(); + renderActions(); + } + private AssetWorkspaceAssetDetails mapDetails( PackerAssetDetailsDTO details, java.util.List actions) { diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java index d73479a4..bab26635 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java @@ -108,6 +108,7 @@ public final class AssetDetailsUiSupport { public static String actionLabel(AssetAction action) { return switch (action) { case REGISTER -> Container.i18n().text(I18n.ASSETS_ACTION_REGISTER); + case DELETE -> Container.i18n().text(I18n.ASSETS_ACTION_DELETE); }; } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/wizards/DeleteAssetDialog.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/wizards/DeleteAssetDialog.java new file mode 100644 index 00000000..0afc7c21 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/wizards/DeleteAssetDialog.java @@ -0,0 +1,84 @@ +package p.studio.workspaces.assets.wizards; + +import javafx.beans.binding.Bindings; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Modality; +import javafx.stage.Stage; +import javafx.stage.Window; +import p.studio.Container; +import p.studio.utilities.i18n.I18n; + +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +public final class DeleteAssetDialog { + private final Stage stage; + private final AtomicReference result = new AtomicReference<>(false); + private final String assetName; + private final TextField confirmationField = new TextField(); + + private DeleteAssetDialog(Window owner, String assetName) { + this.assetName = Objects.requireNonNull(assetName, "assetName").trim(); + this.stage = new Stage(); + stage.initOwner(owner); + stage.initModality(Modality.WINDOW_MODAL); + stage.setTitle(Container.i18n().text(I18n.ASSETS_DELETE_DIALOG_TITLE)); + stage.setScene(new Scene(buildRoot(), 520, 240)); + stage.getScene().getStylesheets().add(Container.theme().getDefaultTheme()); + } + + public static boolean showAndWait(Window owner, String assetName) { + final DeleteAssetDialog dialog = new DeleteAssetDialog(owner, assetName); + dialog.stage.showAndWait(); + return Optional.ofNullable(dialog.result.get()).orElse(false); + } + + private VBox buildRoot() { + final Label title = new Label(Container.i18n().text(I18n.ASSETS_DELETE_DIALOG_TITLE)); + title.getStyleClass().add("studio-launcher-section-title"); + + final Label description = new Label(Container.i18n().format(I18n.ASSETS_DELETE_DIALOG_DESCRIPTION, assetName)); + description.getStyleClass().add("studio-launcher-subtitle"); + description.setWrapText(true); + + confirmationField.setPromptText(Container.i18n().text(I18n.ASSETS_DELETE_DIALOG_PROMPT)); + confirmationField.setMaxWidth(Double.MAX_VALUE); + + final Label note = new Label(Container.i18n().text(I18n.ASSETS_DELETE_DIALOG_NOTE)); + note.getStyleClass().add("studio-launcher-feedback"); + note.setWrapText(true); + + final Button confirmButton = new Button(); + confirmButton.textProperty().bind(Container.i18n().bind(I18n.ASSETS_DELETE_DIALOG_CONFIRM)); + confirmButton.getStyleClass().addAll("studio-button", "studio-button-danger"); + confirmButton.disableProperty().bind(Bindings.createBooleanBinding( + () -> !assetName.equals(confirmationField.getText().trim()), + confirmationField.textProperty())); + confirmButton.setOnAction(ignored -> { + result.set(true); + stage.close(); + }); + + final Button cancelButton = new Button(); + cancelButton.textProperty().bind(Container.i18n().bind(I18n.WIZARD_CANCEL)); + cancelButton.getStyleClass().addAll("studio-button", "studio-button-cancel"); + cancelButton.setOnAction(ignored -> stage.close()); + + final HBox actions = new HBox(12, confirmButton, cancelButton); + actions.setAlignment(Pos.CENTER_RIGHT); + + final VBox root = new VBox(16, title, description, confirmationField, note, actions); + root.setPadding(new Insets(24)); + VBox.setVgrow(confirmationField, Priority.NEVER); + return root; + } +} diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index fbdf2e98..8265f6ec 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -92,6 +92,12 @@ assets.section.diagnostics=Diagnostics assets.section.actions=Actions assets.actions.empty=No actions available for this asset. assets.action.register=Register +assets.action.delete=Delete +assets.deleteDialog.title=Delete Asset +assets.deleteDialog.description=Type the asset name below to confirm deletion of {0}. +assets.deleteDialog.prompt=Type the asset name exactly +assets.deleteDialog.note=Only asset.json is deleted. The asset directory and its remaining files stay on disk. +assets.deleteDialog.confirm=Delete assets.action.includeInBuild=Include In Build assets.action.excludeFromBuild=Exclude From Build assets.action.relocate=Relocate