implements PR-022

This commit is contained in:
bQUARKz 2026-03-16 08:14:55 +00:00
parent a14a0b8b37
commit 165be76128
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
16 changed files with 508 additions and 22 deletions

View File

@ -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/**`

View File

@ -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) 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) 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) 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: Current wave discipline from `PR-11` onward:
@ -91,4 +92,4 @@ Current wave discipline from `PR-11` onward:
Recommended dependency chain: 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`

View File

@ -14,4 +14,6 @@ public interface PackerWorkspaceService {
CreateAssetResult createAsset(CreateAssetRequest request); CreateAssetResult createAsset(CreateAssetRequest request);
RegisterAssetResult registerAsset(RegisterAssetRequest request); RegisterAssetResult registerAsset(RegisterAssetRequest request);
DeleteAssetResult deleteAsset(DeleteAssetRequest request);
} }

View File

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

View File

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

View File

@ -1,5 +1,6 @@
package p.packer.messages.assets; package p.packer.messages.assets;
public enum AssetAction { public enum AssetAction {
REGISTER REGISTER,
DELETE
} }

View File

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

View File

@ -17,6 +17,8 @@ import p.packer.events.PackerEventSink;
import p.packer.events.PackerProgress; import p.packer.events.PackerProgress;
import p.packer.messages.CreateAssetRequest; import p.packer.messages.CreateAssetRequest;
import p.packer.messages.CreateAssetResult; import p.packer.messages.CreateAssetResult;
import p.packer.messages.DeleteAssetRequest;
import p.packer.messages.DeleteAssetResult;
import p.packer.messages.GetAssetDetailsRequest; import p.packer.messages.GetAssetDetailsRequest;
import p.packer.messages.GetAssetDetailsResult; import p.packer.messages.GetAssetDetailsResult;
import p.packer.messages.InitWorkspaceRequest; import p.packer.messages.InitWorkspaceRequest;
@ -27,6 +29,7 @@ import p.packer.messages.RegisterAssetRequest;
import p.packer.messages.RegisterAssetResult; import p.packer.messages.RegisterAssetResult;
import p.packer.PackerWorkspaceService; import p.packer.PackerWorkspaceService;
import p.packer.models.PackerAssetDeclarationParseResult; import p.packer.models.PackerAssetDeclarationParseResult;
import p.packer.models.PackerDeleteAssetEvaluation;
import p.packer.models.PackerAssetIdentity; import p.packer.models.PackerAssetIdentity;
import p.packer.models.PackerRegisterAssetEvaluation; import p.packer.models.PackerRegisterAssetEvaluation;
import p.packer.models.PackerAssetSummary; import p.packer.models.PackerAssetSummary;
@ -166,6 +169,14 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
return writeCoordinator.execute(project, () -> registerAssetInWriteLane(safeRequest, events)); 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( private CreateAssetResult createAssetInWriteLane(
CreateAssetRequest request, CreateAssetRequest request,
PackerOperationEventEmitter events) { 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( private CreateAssetResult failureResult(
PackerOperationEventEmitter events, PackerOperationEventEmitter events,
String summary, String summary,
@ -327,6 +382,21 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
return result; 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 { private void writeManifest(Path manifestPath, CreateAssetRequest request, String assetUuid) throws IOException {
final Map<String, Object> manifest = new LinkedHashMap<>(); final Map<String, Object> manifest = new LinkedHashMap<>();
manifest.put("schema_version", 1); manifest.put("schema_version", 1);
@ -420,4 +490,16 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
private String relativeAssetRoot(PackerProjectContext project, Path assetRoot) { private String relativeAssetRoot(PackerProjectContext project, Path assetRoot) {
return PackerWorkspacePaths.relativeAssetRoot(project, assetRoot).replace('\\', '/'); 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());
}
} }

View File

@ -12,6 +12,7 @@ import p.packer.messages.diagnostics.PackerDiagnosticSeverity;
import p.packer.models.PackerAssetActionAvailability; import p.packer.models.PackerAssetActionAvailability;
import p.packer.models.PackerAssetDeclaration; import p.packer.models.PackerAssetDeclaration;
import p.packer.models.PackerAssetDeclarationParseResult; import p.packer.models.PackerAssetDeclarationParseResult;
import p.packer.models.PackerDeleteAssetEvaluation;
import p.packer.models.PackerDiagnostic; import p.packer.models.PackerDiagnostic;
import p.packer.models.PackerRegisterAssetEvaluation; import p.packer.models.PackerRegisterAssetEvaluation;
import p.packer.models.PackerRegistryEntry; import p.packer.models.PackerRegistryEntry;
@ -43,6 +44,7 @@ public final class PackerAssetActionReadService {
final PackerProjectContext project = Objects.requireNonNull(request, "request").project(); final PackerProjectContext project = Objects.requireNonNull(request, "request").project();
final PackerRuntimeSnapshot snapshot = runtimeRegistry.getOrLoad(project).snapshot(); final PackerRuntimeSnapshot snapshot = runtimeRegistry.getOrLoad(project).snapshot();
final PackerRegisterAssetEvaluation registerEvaluation = evaluateRegister(snapshot, project, request.assetReference()); final PackerRegisterAssetEvaluation registerEvaluation = evaluateRegister(snapshot, project, request.assetReference());
final PackerDeleteAssetEvaluation deleteEvaluation = evaluateDelete(snapshot, project, request.assetReference());
final List<PackerAssetActionAvailability> actions = new ArrayList<>(); final List<PackerAssetActionAvailability> actions = new ArrayList<>();
if (registerEvaluation.resolved().registryEntry().isEmpty()) { if (registerEvaluation.resolved().registryEntry().isEmpty()) {
actions.add(new PackerAssetActionAvailability( actions.add(new PackerAssetActionAvailability(
@ -51,11 +53,18 @@ public final class PackerAssetActionReadService {
true, true,
registerEvaluation.reason())); registerEvaluation.reason()));
} }
if (deleteEvaluation.resolved().runtimeAsset().isPresent()) {
actions.add(new PackerAssetActionAvailability(
AssetAction.DELETE,
deleteEvaluation.canDelete(),
true,
deleteEvaluation.reason()));
}
final PackerOperationStatus status; final PackerOperationStatus status;
if (registerEvaluation.resolved().runtimeAsset().isEmpty()) { if (registerEvaluation.resolved().runtimeAsset().isEmpty() && deleteEvaluation.resolved().runtimeAsset().isEmpty()) {
status = PackerOperationStatus.FAILED; 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; status = PackerOperationStatus.PARTIAL;
} else { } else {
status = PackerOperationStatus.SUCCESS; status = PackerOperationStatus.SUCCESS;
@ -64,7 +73,7 @@ public final class PackerAssetActionReadService {
status, status,
"Asset action capabilities resolved from runtime snapshot.", "Asset action capabilities resolved from runtime snapshot.",
PackerReadMessageMapper.toAssetActionAvailabilityDTOs(actions), PackerReadMessageMapper.toAssetActionAvailabilityDTOs(actions),
PackerReadMessageMapper.toDiagnosticDTOs(registerEvaluation.diagnostics())); PackerReadMessageMapper.toDiagnosticDTOs(combinedDiagnostics(registerEvaluation.diagnostics(), deleteEvaluation.diagnostics())));
} }
public PackerRegisterAssetEvaluation evaluateRegister( public PackerRegisterAssetEvaluation evaluateRegister(
@ -120,6 +129,30 @@ public final class PackerAssetActionReadService {
return new PackerRegisterAssetEvaluation(resolved, diagnostics, true, null); 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) { private String firstBlockingReason(List<PackerDiagnostic> diagnostics, String fallback) {
return diagnostics.stream() return diagnostics.stream()
.filter(PackerDiagnostic::blocking) .filter(PackerDiagnostic::blocking)
@ -128,4 +161,12 @@ public final class PackerAssetActionReadService {
.findFirst() .findFirst()
.orElse(fallback); .orElse(fallback);
} }
private List<PackerDiagnostic> combinedDiagnostics(
List<PackerDiagnostic> left,
List<PackerDiagnostic> right) {
final List<PackerDiagnostic> combined = new ArrayList<>(left);
combined.addAll(right);
return combined;
}
} }

View File

@ -65,4 +65,15 @@ public final class PackerRuntimePatchService {
} }
return new PackerRuntimeSnapshot(generation, updatedRegistry, updatedAssets); 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);
}
} }

View File

@ -14,6 +14,8 @@ import p.packer.events.PackerEvent;
import p.packer.events.PackerEventKind; import p.packer.events.PackerEventKind;
import p.packer.messages.CreateAssetRequest; import p.packer.messages.CreateAssetRequest;
import p.packer.messages.CreateAssetResult; import p.packer.messages.CreateAssetResult;
import p.packer.messages.DeleteAssetRequest;
import p.packer.messages.DeleteAssetResult;
import p.packer.messages.GetAssetActionsRequest; import p.packer.messages.GetAssetActionsRequest;
import p.packer.messages.GetAssetDetailsRequest; import p.packer.messages.GetAssetDetailsRequest;
import p.packer.messages.ListAssetsRequest; import p.packer.messages.ListAssetsRequest;
@ -183,11 +185,15 @@ final class FileSystemPackerWorkspaceServiceTest {
AssetReference.forRelativeAssetRoot("orphans/ui_sounds"))); AssetReference.forRelativeAssetRoot("orphans/ui_sounds")));
assertEquals(PackerOperationStatus.SUCCESS, result.status()); assertEquals(PackerOperationStatus.SUCCESS, result.status());
assertEquals(1, result.actions().size()); assertEquals(2, result.actions().size());
assertEquals(AssetAction.REGISTER, result.actions().getFirst().action()); assertEquals(AssetAction.REGISTER, result.actions().get(0).action());
assertTrue(result.actions().getFirst().visible()); assertTrue(result.actions().get(0).visible());
assertTrue(result.actions().getFirst().enabled()); assertTrue(result.actions().get(0).enabled());
assertNull(result.actions().getFirst().reason()); 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 @Test
@ -200,7 +206,9 @@ final class FileSystemPackerWorkspaceServiceTest {
AssetReference.forAssetId(1))); AssetReference.forAssetId(1)));
assertEquals(PackerOperationStatus.SUCCESS, result.status()); 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 @Test
@ -246,7 +254,8 @@ final class FileSystemPackerWorkspaceServiceTest {
final var actionsResult = service.getAssetActions(new GetAssetActionsRequest( final var actionsResult = service.getAssetActions(new GetAssetActionsRequest(
project(projectRoot), project(projectRoot),
registerResult.assetReference())); registerResult.assetReference()));
assertTrue(actionsResult.actions().isEmpty()); assertEquals(1, actionsResult.actions().size());
assertEquals(AssetAction.DELETE, actionsResult.actions().getFirst().action());
assertEquals(1, loader.loadCount()); assertEquals(1, loader.loadCount());
} }
@ -260,9 +269,12 @@ final class FileSystemPackerWorkspaceServiceTest {
AssetReference.forRelativeAssetRoot("bad"))); AssetReference.forRelativeAssetRoot("bad")));
assertEquals(PackerOperationStatus.PARTIAL, result.status()); assertEquals(PackerOperationStatus.PARTIAL, result.status());
assertEquals(1, result.actions().size()); assertEquals(2, result.actions().size());
assertFalse(result.actions().getFirst().enabled()); assertEquals(AssetAction.REGISTER, result.actions().get(0).action());
assertNotNull(result.actions().getFirst().reason()); 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 @Test
@ -322,9 +334,12 @@ final class FileSystemPackerWorkspaceServiceTest {
final var actions = service.getAssetActions(new GetAssetActionsRequest( final var actions = service.getAssetActions(new GetAssetActionsRequest(
project(projectRoot), project(projectRoot),
AssetReference.forRelativeAssetRoot("orphans/ui_clone"))); AssetReference.forRelativeAssetRoot("orphans/ui_clone")));
assertEquals(1, actions.actions().size()); assertEquals(2, actions.actions().size());
assertFalse(actions.actions().getFirst().enabled()); assertEquals(AssetAction.REGISTER, actions.actions().get(0).action());
assertNotNull(actions.actions().getFirst().reason()); 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( final RegisterAssetResult registerResult = service.registerAsset(new RegisterAssetRequest(
project(projectRoot), project(projectRoot),
@ -333,6 +348,55 @@ final class FileSystemPackerWorkspaceServiceTest {
assertNull(registerResult.assetReference()); 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 @Test
void rejectsUnsupportedFormatForSelectedFamily() { void rejectsUnsupportedFormatForSelectedFamily() {
final Path projectRoot = tempDir.resolve("unsupported"); final Path projectRoot = tempDir.resolve("unsupported");

View File

@ -102,10 +102,15 @@ public enum I18n {
ASSETS_SECTION_ACTIONS("assets.section.actions"), ASSETS_SECTION_ACTIONS("assets.section.actions"),
ASSETS_ACTIONS_EMPTY("assets.actions.empty"), ASSETS_ACTIONS_EMPTY("assets.actions.empty"),
ASSETS_ACTION_REGISTER("assets.action.register"), ASSETS_ACTION_REGISTER("assets.action.register"),
ASSETS_ACTION_DELETE("assets.action.delete"),
ASSETS_ACTION_INCLUDE_IN_BUILD("assets.action.includeInBuild"), ASSETS_ACTION_INCLUDE_IN_BUILD("assets.action.includeInBuild"),
ASSETS_ACTION_EXCLUDE_FROM_BUILD("assets.action.excludeFromBuild"), ASSETS_ACTION_EXCLUDE_FROM_BUILD("assets.action.excludeFromBuild"),
ASSETS_ACTION_RELOCATE("assets.action.relocate"), 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_PREVIEW_TITLE("assets.mutation.previewTitle"),
ASSETS_MUTATION_SECTION_CHANGES("assets.mutation.section.changes"), ASSETS_MUTATION_SECTION_CHANGES("assets.mutation.section.changes"),
ASSETS_MUTATION_SECTION_AFFECTED_ASSET("assets.mutation.section.affectedAsset"), ASSETS_MUTATION_SECTION_AFFECTED_ASSET("assets.mutation.section.affectedAsset"),

View File

@ -11,6 +11,8 @@ import javafx.scene.layout.VBox;
import p.packer.messages.AssetReference; import p.packer.messages.AssetReference;
import p.packer.dtos.PackerAssetActionAvailabilityDTO; import p.packer.dtos.PackerAssetActionAvailabilityDTO;
import p.packer.dtos.PackerAssetDetailsDTO; import p.packer.dtos.PackerAssetDetailsDTO;
import p.packer.messages.DeleteAssetRequest;
import p.packer.messages.DeleteAssetResult;
import p.packer.messages.GetAssetDetailsRequest; import p.packer.messages.GetAssetDetailsRequest;
import p.packer.messages.GetAssetActionsRequest; import p.packer.messages.GetAssetActionsRequest;
import p.packer.messages.RegisterAssetRequest; 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.StudioAssetsDetailsViewStateChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent; import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceSelectionRequestedEvent; 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.StudioEventAware;
import p.studio.workspaces.framework.StudioEventBindings; import p.studio.workspaces.framework.StudioEventBindings;
@ -302,12 +305,32 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
if (actionRunning || viewState.selectedAssetReference() == null) { if (actionRunning || viewState.selectedAssetReference() == null) {
return; 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; actionRunning = true;
actionFeedbackMessage = null; actionFeedbackMessage = null;
renderActions(); renderActions();
switch (action.action()) { final AssetReference assetReference = viewState.selectedAssetReference();
case REGISTER -> Container.backgroundTasks().submit(() -> registerSelectedAsset(viewState.selectedAssetReference())); Container.backgroundTasks().submit(() -> deleteSelectedAsset(assetReference));
}
} }
private void registerSelectedAsset(AssetReference assetReference) { private void registerSelectedAsset(AssetReference assetReference) {
@ -341,6 +364,36 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
renderActions(); 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( private AssetWorkspaceAssetDetails mapDetails(
PackerAssetDetailsDTO details, PackerAssetDetailsDTO details,
java.util.List<PackerAssetActionAvailabilityDTO> actions) { java.util.List<PackerAssetActionAvailabilityDTO> actions) {

View File

@ -108,6 +108,7 @@ public final class AssetDetailsUiSupport {
public static String actionLabel(AssetAction action) { public static String actionLabel(AssetAction action) {
return switch (action) { return switch (action) {
case REGISTER -> Container.i18n().text(I18n.ASSETS_ACTION_REGISTER); case REGISTER -> Container.i18n().text(I18n.ASSETS_ACTION_REGISTER);
case DELETE -> Container.i18n().text(I18n.ASSETS_ACTION_DELETE);
}; };
} }

View File

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

View File

@ -92,6 +92,12 @@ assets.section.diagnostics=Diagnostics
assets.section.actions=Actions assets.section.actions=Actions
assets.actions.empty=No actions available for this asset. assets.actions.empty=No actions available for this asset.
assets.action.register=Register 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.includeInBuild=Include In Build
assets.action.excludeFromBuild=Exclude From Build assets.action.excludeFromBuild=Exclude From Build
assets.action.relocate=Relocate assets.action.relocate=Relocate