implements PR-022
This commit is contained in:
parent
a14a0b8b37
commit
165be76128
@ -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/**`
|
||||
@ -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`
|
||||
|
||||
@ -14,4 +14,6 @@ public interface PackerWorkspaceService {
|
||||
CreateAssetResult createAsset(CreateAssetRequest request);
|
||||
|
||||
RegisterAssetResult registerAsset(RegisterAssetRequest request);
|
||||
|
||||
DeleteAssetResult deleteAsset(DeleteAssetRequest request);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
package p.packer.messages.assets;
|
||||
|
||||
public enum AssetAction {
|
||||
REGISTER
|
||||
REGISTER,
|
||||
DELETE
|
||||
}
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
package p.packer.models;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public record PackerDeleteAssetEvaluation(
|
||||
PackerResolvedAssetReference resolved,
|
||||
List<PackerDiagnostic> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String> 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<String, Object> 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<PackerRegistryEntry> updatedEntries = registry.assets().stream()
|
||||
.filter(candidate -> candidate.assetId() != entry.assetId())
|
||||
.toList();
|
||||
return registry.withAssets(updatedEntries, registry.nextAssetId());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<PackerAssetActionAvailability> 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<PackerDiagnostic> 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<PackerDiagnostic> diagnostics, String fallback) {
|
||||
return diagnostics.stream()
|
||||
.filter(PackerDiagnostic::blocking)
|
||||
@ -128,4 +161,12 @@ public final class PackerAssetActionReadService {
|
||||
.findFirst()
|
||||
.orElse(fallback);
|
||||
}
|
||||
|
||||
private List<PackerDiagnostic> combinedDiagnostics(
|
||||
List<PackerDiagnostic> left,
|
||||
List<PackerDiagnostic> right) {
|
||||
final List<PackerDiagnostic> combined = new ArrayList<>(left);
|
||||
combined.addAll(right);
|
||||
return combined;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<PackerRuntimeAsset> updatedAssets = snapshot.assets().stream()
|
||||
.filter(asset -> !asset.assetRoot().equals(assetRoot.toAbsolutePath().normalize()))
|
||||
.toList();
|
||||
return new PackerRuntimeSnapshot(generation, updatedRegistry, updatedAssets);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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<PackerAssetActionAvailabilityDTO> actions) {
|
||||
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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<Boolean> 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user