From 59c531a980a347e9aa35b1dc91d22e709c4f0fa0 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Fri, 3 Apr 2026 16:17:02 -0400 Subject: [PATCH 01/16] New Asset Exists Modal Resolution Feature Works with uploading and moving assets. Resolves #14381. --- lang/en/messages.php | 12 + .../assets/Browser/AssetBrowserMixin.js | 40 +- .../js/components/assets/Browser/Browser.vue | 371 +++++++++++++++++- .../js/components/assets/Browser/Table.vue | 3 +- resources/js/components/assets/Upload.vue | 62 +-- resources/js/components/assets/Uploader.vue | 28 +- src/Actions/MoveAsset.php | 74 +++- src/Assets/Asset.php | 2 + src/Exceptions/AssetConflictException.php | 20 + src/Http/Controllers/CP/ActionController.php | 9 +- .../CP/Assets/AssetsController.php | 16 +- src/Listeners/ClearAssetGlideCache.php | 7 + tests/Actions/MoveAssetTest.php | 118 ++++++ 13 files changed, 688 insertions(+), 74 deletions(-) create mode 100644 src/Exceptions/AssetConflictException.php create mode 100644 tests/Actions/MoveAssetTest.php diff --git a/lang/en/messages.php b/lang/en/messages.php index ae79894ece6..5edbfe45c25 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -272,6 +272,18 @@ 'updater_require_version_command' => 'To require this specific version, run the following command', 'updater_update_to_latest_command' => 'To update to the latest version, run the following command', 'uploader_append_timestamp' => 'Append timestamp', + 'asset_conflict_title' => 'File conflict', + 'asset_conflict_message' => ':existing_descriptor item named ":filename" already exists in this location. Do you want to replace it with the :moving_age one you\'re moving?', + 'asset_upload_conflict_message' => 'An item named ":filename" already exists in this location. What would you like to do?', + 'asset_conflict_a_newer' => 'A newer', + 'asset_conflict_an_older' => 'An older', + 'asset_conflict_newer' => 'newer', + 'asset_conflict_older' => 'older', + 'asset_conflict_cancel' => 'Cancel', + 'asset_conflict_keep_both' => 'Keep Both', + 'asset_conflict_overwrite' => 'Overwrite', + 'asset_conflict_apply_to_all' => 'Do this for all remaining conflicts', + 'asset_conflict_pending' => 'Waiting for conflict decision', 'uploader_choose_new_filename' => 'Choose new filename', 'uploader_discard_use_existing' => 'Discard and use existing file', 'uploader_overwrite_existing' => 'Overwrite existing file', diff --git a/resources/js/components/assets/Browser/AssetBrowserMixin.js b/resources/js/components/assets/Browser/AssetBrowserMixin.js index 9bd2664c71a..f243f3c1717 100644 --- a/resources/js/components/assets/Browser/AssetBrowserMixin.js +++ b/resources/js/components/assets/Browser/AssetBrowserMixin.js @@ -6,6 +6,10 @@ export default { folderActionUrl: String, folders: Array, path: String, + selectedAssets: { + type: Array, + default: () => [], + }, restrictFolderNavigation: Boolean, creatingFolder: Boolean, creatingFolderError: Boolean, @@ -82,25 +86,52 @@ export default { return folder.actions.some((action) => action.handle === 'move_asset_folder'); }, + getDraggingAssetSelections() { + const selectedAssetIds = Array.isArray(this.selectedAssets) ? this.selectedAssets : []; + + if (selectedAssetIds.includes(this.draggingAsset)) { + return selectedAssetIds; + } + + return this.draggingAsset ? [this.draggingAsset] : []; + }, + handleFolderDrop(destinationFolder) { if (this.draggingAsset) { let asset = this.assets.find((asset) => asset.id === this.draggingAsset); let action = asset.actions.find((action) => action.handle === 'move_asset'); + const selections = this.getDraggingAssetSelections(); - if (!action) { + if (!action || selections.length === 0) { return; } const payload = { action: action.handle, context: action.context, - selections: [this.draggingAsset], + selections, values: { folder: destinationFolder.path }, }; this.$axios .post(this.actionUrl, payload) - .then(response => this.$emit('action-completed', true, response)) + .then(({ data }) => { + if (data.success === false && data.conflict?.type === 'asset_move') { + this.$emit('asset-move-conflict', { + action, + asset, + destinationFolder, + selections, + message: data.message, + conflict: data.conflict, + }); + + return; + } + + this.$emit('action-completed', data.success !== false, data); + }) + .catch((error) => this.$emit('action-completed', false, error.response?.data || {})) .finally(() => this.draggingAsset = null); } @@ -121,7 +152,8 @@ export default { this.$axios .post(this.folderActionUrl, payload) - .then(response => this.$emit('action-completed', true, response)) + .then(({ data }) => this.$emit('action-completed', data.success !== false, data)) + .catch((error) => this.$emit('action-completed', false, error.response?.data || {})) .finally(() => this.draggingFolder = null); } }, diff --git a/resources/js/components/assets/Browser/Browser.vue b/resources/js/components/assets/Browser/Browser.vue index deb04499e31..fdfdf0f8429 100644 --- a/resources/js/components/assets/Browser/Browser.vue +++ b/resources/js/components/assets/Browser/Browser.vue @@ -181,6 +181,53 @@ @action-started="actionStarted" @action-completed="actionCompleted" /> + + +

{{ moveConflictMessage }}

+ + +
+ + +

{{ uploadConflictMessage }}

+ + +
@@ -214,6 +261,8 @@ import { Icon, ToggleGroup, ToggleItem, + Modal, + Checkbox, } from '@ui'; import Breadcrumbs from './Breadcrumbs.vue'; import useCheckerboard from '@/composables/checkerboard.js'; @@ -249,6 +298,8 @@ export default { Icon, ToggleGroup, ToggleItem, + Modal, + Checkbox, }, props: { @@ -301,6 +352,17 @@ export default { creatingFolder: false, creatingFolderError: false, uploads: [], + uploadConflictPolicy: null, + uploadConflictApplyToAll: false, + uploadConflictUploadId: null, + uploadConflictMessage: '', + showUploadConflictModal: false, + uploadConflictQueue: [], + moveConflictContext: null, + moveConflictMessage: '', + showMoveConflictModal: false, + moveConflictApplyToAll: false, + moveConflictPolicy: null, page: 1, preferencesPrefix: `assets.${this.container.id}`, meta: {}, @@ -418,6 +480,7 @@ export default { folders: this.folders, restrictFolderNavigation: this.restrictFolderNavigation, path: this.path, + selectedAssets: this.selectedAssets, creatingFolder: this.creatingFolder, creatingFolderError: this.creatingFolderError, }; @@ -435,10 +498,15 @@ export default { this.creatingFolder = false; this.creatingFolderError = false; }, + 'asset-move-conflict': this.openMoveConflictModal, 'prevent-dragging': (preventDragging) => (this.preventDragging = preventDragging), 'update:creatingFolderError': (value) => (this.creatingFolderError = value), }; }, + + showMoveConflictApplyToAll() { + return (this.moveConflictContext?.pendingSelections?.length || 0) > 1; + }, }, mounted() { @@ -542,7 +610,13 @@ export default { this.loading = true; }, - actionCompleted() { + actionCompleted(successful = null, response = {}) { + if (successful === true && response.message !== false) { + this.$toast.success(response.message || __('Action completed')); + } else if (successful === false) { + this.$toast.error(response.message || __('Action failed')); + } + // Intentionally not completing the loading state here since // the listing will refresh and immediately restart it. // this.loading = false; @@ -550,6 +624,11 @@ export default { this.$refs.listing.refresh(); }, + actionFailed(response = {}) { + this.loading = false; + this.$toast.error(response.message || __('Action failed')); + }, + assetSaved() { this.loadAssets(); }, @@ -724,7 +803,15 @@ export default { this.lastItemClicked = index; }, - uploadCompleted(asset) { + uploadCompleted(asset, uploads, upload) { + if (['overwrite', 'timestamp'].includes(upload?.resolution)) { + const urls = this.getUploadConflictCacheBustUrls(upload); + + if (urls.length) { + Statamic.$callbacks.call('bustAndReloadImageCaches', urls); + } + } + if (this.autoselectUploads) { this.sortColumn = 'last_modified'; this.sortDirection = 'desc'; @@ -743,11 +830,289 @@ export default { uploadError(upload, uploads) { this.uploads = uploads; - this.$toast.error(upload.errorMessage); + + if (upload.errorStatus !== 409) { + this.$toast.error(upload.errorMessage); + return; + } + + if (this.uploadConflictPolicy) { + this.applyUploadConflict(upload, this.uploadConflictPolicy); + return; + } + + this.enqueueUploadConflict(upload.id); + this.openNextUploadConflictFromQueue(); }, uploadsUpdated(uploads) { this.uploads = uploads; + + if (uploads.length === 0) { + this.uploadConflictPolicy = null; + this.uploadConflictApplyToAll = false; + this.uploadConflictQueue = []; + this.uploadConflictUploadId = null; + this.showUploadConflictModal = false; + } else { + const uploadIds = new Set(uploads.map((upload) => upload.id)); + this.uploadConflictQueue = this.uploadConflictQueue.filter((id) => uploadIds.has(id)); + } + }, + + getUploadById(id) { + return this.uploads.find((upload) => upload.id === id); + }, + + openUploadConflictModal(upload) { + this.uploadConflictUploadId = upload.id; + this.uploadConflictMessage = __('messages.asset_upload_conflict_message', { + filename: upload.basename, + }); + this.showUploadConflictModal = true; + }, + + resolveUploadConflict(strategy) { + const currentConflictUploadId = this.uploadConflictUploadId; + const upload = this.getUploadById(this.uploadConflictUploadId); + + if (this.uploadConflictApplyToAll) { + this.uploadConflictPolicy = strategy; + + this.uploads + .filter((item) => item.errorStatus === 409) + .forEach((item) => this.applyUploadConflict(item, strategy)); + + this.uploadConflictQueue = []; + this.uploadConflictUploadId = null; + this.uploadConflictMessage = ''; + this.showUploadConflictModal = false; + } else if (upload) { + this.applyUploadConflict(upload, strategy); + this.dequeueUploadConflict(currentConflictUploadId); + this.uploadConflictUploadId = null; + this.uploadConflictMessage = ''; + + // Keep the same modal open and dynamically swap to the next conflict. + const hasNextConflict = this.openNextUploadConflictFromQueue(); + + if (!hasNextConflict) { + this.showUploadConflictModal = false; + } + } else { + this.showUploadConflictModal = false; + } + + this.uploadConflictApplyToAll = false; + }, + + applyUploadConflict(upload, strategy) { + if (strategy === 'cancel') { + upload.skip(); + return; + } + + upload.retry({ + option: strategy, + }, { + conflict: upload.conflict, + resolution: strategy, + }); + }, + + getUploadConflictCacheBustUrls(upload) { + if (upload?.conflict?.existing) { + return [upload.conflict.existing.preview, upload.conflict.existing.thumbnail].filter(Boolean); + } + + const folderPath = (this.folder?.path || '').replace(/^\/+|\/+$/g, ''); + const basename = upload?.basename || ''; + const fullPath = [folderPath, basename].filter(Boolean).join('/'); + const existingAsset = this.assets.find((asset) => { + if (asset.path === fullPath) { + return true; + } + + return folderPath === '' && asset.basename === basename; + }); + + if (!existingAsset) { + return []; + } + + return [existingAsset.preview, existingAsset.thumbnail].filter(Boolean); + }, + + enqueueUploadConflict(id) { + if (!id || this.uploadConflictQueue.includes(id)) { + return; + } + + this.uploadConflictQueue.push(id); + }, + + dequeueUploadConflict(id) { + if (!id) { + return; + } + + this.uploadConflictQueue = this.uploadConflictQueue.filter((queuedId) => queuedId !== id); + }, + + openNextUploadConflictFromQueue() { + if (this.showUploadConflictModal || this.uploadConflictPolicy) { + if (!this.uploadConflictUploadId) { + // Modal is visible but no active conflict selected yet. + // Continue to resolve the next queued conflict. + } else { + return false; + } + } + + while (this.uploadConflictQueue.length > 0) { + const nextConflictId = this.uploadConflictQueue[0]; + const nextConflict = this.getUploadById(nextConflictId); + + if (!nextConflict || nextConflict.errorStatus !== 409) { + this.uploadConflictQueue.shift(); + continue; + } + + this.openUploadConflictModal(nextConflict); + return true; + } + + return false; + }, + + openMoveConflictModal({ action, asset, destinationFolder, selections, message, conflict }) { + this.moveConflictContext = { + action, + asset, + destinationFolder, + pendingSelections: Array.from(new Set((selections || [asset?.id]).filter(Boolean))), + conflict, + }; + this.moveConflictMessage = message; + this.showMoveConflictModal = true; + }, + + async resolveMoveConflict(strategy) { + const conflictContext = this.moveConflictContext; + this.showMoveConflictModal = false; + this.moveConflictContext = null; + + if (!conflictContext) { + return; + } + + if (this.moveConflictApplyToAll && strategy !== 'cancel') { + this.moveConflictPolicy = strategy; + } + + this.moveConflictApplyToAll = false; + this.actionStarted(); + await this.continueMoveConflictResolution(conflictContext, strategy); + }, + + async continueMoveConflictResolution(context, strategy = null) { + const conflictAssetId = context.conflict?.asset?.id; + const resolution = strategy ?? this.moveConflictPolicy; + + if (conflictAssetId) { + context.pendingSelections = context.pendingSelections.filter((id) => id !== conflictAssetId); + } + + if (strategy && strategy !== 'cancel' && conflictAssetId) { + const resolutionResult = await this.runMoveConflictAction(context, [conflictAssetId], strategy); + + if (resolutionResult.success === false) { + this.moveConflictPolicy = null; + this.actionFailed(resolutionResult); + return; + } + + if (strategy === 'overwrite') { + const originalPreview = context.conflict?.existing?.preview; + const originalThumbnail = context.conflict?.existing?.thumbnail; + Statamic.$callbacks.call('bustAndReloadImageCaches', [originalPreview, originalThumbnail]); + } + } + + while (context.pendingSelections.length > 0) { + const response = await this.runMoveConflictAction(context, context.pendingSelections, null); + + if (response.success !== false) { + this.moveConflictPolicy = null; + this.actionCompleted(true, response); + return; + } + + if (response.conflict?.type !== 'asset_move') { + this.moveConflictPolicy = null; + this.actionFailed(response); + return; + } + + context.conflict = response.conflict; + this.moveConflictMessage = response.message; + + const currentConflictAssetId = response.conflict?.asset?.id; + + if (!currentConflictAssetId) { + this.moveConflictPolicy = null; + this.actionFailed(response); + return; + } + + if (resolution) { + await this.continueMoveConflictResolution(context, resolution); + return; + } + + this.moveConflictContext = context; + this.showMoveConflictModal = true; + return; + } + + this.moveConflictPolicy = null; + this.actionCompleted(true, { + message: false, + }); + }, + + async runMoveConflictAction(context, selections, strategy = null) { + const selectedAssetIds = Array.from(new Set((selections || []).filter(Boolean))); + + if (selectedAssetIds.length === 0) { + return { + success: true, + message: false, + }; + } + + const payload = { + action: context.action.handle, + context: { + ...context.action.context, + ...(strategy ? { conflict: strategy } : {}), + }, + selections: selectedAssetIds, + values: { + folder: context.destinationFolder.path, + }, + }; + + try { + const { data } = await this.$axios.post(this.actionUrl, payload); + + return data || {}; + } catch ({ response }) { + return response?.data || { + success: false, + message: __('Action failed'), + }; + } }, addToCommandPalette() { diff --git a/resources/js/components/assets/Browser/Table.vue b/resources/js/components/assets/Browser/Table.vue index f8ed0777a9c..f61a1da75ca 100644 --- a/resources/js/components/assets/Browser/Table.vue +++ b/resources/js/components/assets/Browser/Table.vue @@ -161,7 +161,8 @@ export default { loading: Boolean, columns: Array, visibleColumns: Array, - isSearching: Boolean + isSearching: Boolean, + selectedAssets: Array, }, watch: { diff --git a/resources/js/components/assets/Upload.vue b/resources/js/components/assets/Upload.vue index 3b6ce478b23..e9676c09ec3 100644 --- a/resources/js/components/assets/Upload.vue +++ b/resources/js/components/assets/Upload.vue @@ -14,44 +14,21 @@
- - - - - - - - - + + {{ __('messages.asset_conflict_pending') }} +
- - - - - - diff --git a/resources/js/components/assets/Uploader.vue b/resources/js/components/assets/Uploader.vue index e67e8b2c003..3f051e76e1b 100644 --- a/resources/js/components/assets/Uploader.vue +++ b/resources/js/components/assets/Uploader.vue @@ -168,7 +168,7 @@ export default { return readEntries(); }, - addFile(file, data = {}) { + addFile(file, data = {}, meta = {}) { if (!this.enabled) return; const id = uniqid(); @@ -181,8 +181,11 @@ export default { percent: 0, errorMessage: null, errorStatus: null, + conflict: meta.conflict ?? null, + resolution: meta.resolution ?? null, instance: upload, - retry: (opts) => this.retry(id, opts), + retry: (opts, retryMeta = {}) => this.retry(id, opts, retryMeta), + skip: () => this.skip(id), }); }, @@ -266,7 +269,8 @@ export default { }, handleUploadSuccess(id, response) { - this.$emit('upload-complete', response.data, this.uploads); + const upload = this.findUpload(id); + this.$emit('upload-complete', response.data, this.uploads, upload); this.uploads.splice(this.findUploadIndex(id), 1); this.handleToasts(response._toasts ?? []); @@ -291,6 +295,7 @@ export default { upload.errorMessage = msg; upload.errorStatus = status; + upload.conflict = response?.conflict ?? null; this.$emit('error', upload, this.uploads); this.processUploadQueue(); }, @@ -299,10 +304,21 @@ export default { toasts.forEach((toast) => Statamic.$toast[toast.type](toast.message, { duration: toast.duration })); }, - retry(id, args) { - let file = this.findUpload(id).instance.form.get('file'); - this.addFile(file, args); + retry(id, args = {}, meta = {}) { + const currentUpload = this.findUpload(id); + let file = currentUpload.instance.form.get('file'); + + this.addFile(file, args, { + conflict: meta.conflict ?? currentUpload.conflict ?? null, + resolution: meta.resolution ?? currentUpload.resolution ?? null, + }); + + this.uploads.splice(this.findUploadIndex(id), 1); + }, + + skip(id) { this.uploads.splice(this.findUploadIndex(id), 1); + this.processUploadQueue(); }, }, }; diff --git a/src/Actions/MoveAsset.php b/src/Actions/MoveAsset.php index 3abd2684ac3..c8b52713e5f 100644 --- a/src/Actions/MoveAsset.php +++ b/src/Actions/MoveAsset.php @@ -3,8 +3,12 @@ namespace Statamic\Actions; use Statamic\Contracts\Assets\Asset; +use Statamic\Exceptions\AssetConflictException; use Statamic\Facades\AssetContainer; use Statamic\Facades\Blink; +use Statamic\Facades\Glide; +use Statamic\Facades\Path; +use Statamic\Support\Str; class MoveAsset extends Action { @@ -39,7 +43,75 @@ public function confirmationText() public function run($assets, $values) { - $ids = $assets->each->move($values['folder'])->map->id()->all(); + $folder = $values['folder']; + $strategy = $this->context['conflict'] ?? 'cancel'; + $timestamp = now()->timestamp; + $ids = []; + + foreach ($assets as $index => $asset) { + $destinationPath = Str::removeLeft(Path::tidy($folder.'/'.$asset->basename()), '/'); + $conflicts = $asset->path() !== $destinationPath && $asset->disk()->exists($destinationPath); + + if ($conflicts) { + $existingAsset = $asset->container()->asset($destinationPath); + $sourceLastModified = $asset->disk()->lastModified($asset->path()); + $destinationLastModified = $asset->disk()->lastModified($destinationPath); + $movingAge = $sourceLastModified >= $destinationLastModified + ? __('statamic::messages.asset_conflict_newer') + : __('statamic::messages.asset_conflict_older'); + $existingDescriptor = $sourceLastModified >= $destinationLastModified + ? __('statamic::messages.asset_conflict_a_newer') + : __('statamic::messages.asset_conflict_an_older'); + $existingAge = $sourceLastModified >= $destinationLastModified + ? __('statamic::messages.asset_conflict_older') + : __('statamic::messages.asset_conflict_newer'); + + if ($strategy === 'overwrite') { + $assetForGlideCacheClear = $existingAsset ?? $asset->container()->makeAsset($destinationPath); + Glide::clearAsset($assetForGlideCacheClear); + $ids[] = $asset->move($folder)->id(); + + continue; + } + + if ($strategy === 'timestamp') { + $filename = $asset->filename().'-'.$timestamp; + + if ($index > 0) { + $filename .= '-'.$index; + } + + $ids[] = $asset->moveUnique($folder, $filename)->id(); + + continue; + } + + throw new AssetConflictException( + __('statamic::messages.asset_conflict_message', [ + 'filename' => $asset->basename(), + 'existing_descriptor' => $existingDescriptor, + 'moving_age' => $movingAge, + 'existing_age' => $existingAge, + ]), + [ + 'conflict' => [ + 'type' => 'asset_move', + 'asset' => [ + 'id' => $asset->id(), + 'basename' => $asset->basename(), + ], + 'existing' => [ + 'preview' => $existingAsset ? ($existingAsset->container()->accessible() ? $existingAsset->url() : $existingAsset->thumbnailUrl()) : null, + 'thumbnail' => $existingAsset?->thumbnailUrl('small'), + ], + 'destination' => $folder, + ], + ], + ); + } + + $ids[] = $asset->move($folder)->id(); + } return [ 'ids' => $ids, diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index f820b780c9b..7da41a56f16 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -763,6 +763,7 @@ public function move($folder, $filename = null) { $filename = Uploader::getSafeFilename($filename ?: $this->filename()); $oldPath = $this->path(); + $oldMetaCacheKey = $this->metaCacheKey(); $oldMetaPath = $this->metaPath(); $newPath = Str::removeLeft(Path::tidy($folder.'/'.$filename.'.'.pathinfo($oldPath, PATHINFO_EXTENSION)), '/'); @@ -772,6 +773,7 @@ public function move($folder, $filename = null) $this->hydrate(); $this->disk()->rename($oldPath, $newPath); + $this->cacheStore()->forget($oldMetaCacheKey); $this->path($newPath); $this->save(); diff --git a/src/Exceptions/AssetConflictException.php b/src/Exceptions/AssetConflictException.php new file mode 100644 index 00000000000..9d0b28bc814 --- /dev/null +++ b/src/Exceptions/AssetConflictException.php @@ -0,0 +1,20 @@ +context; + } +} diff --git a/src/Http/Controllers/CP/ActionController.php b/src/Http/Controllers/CP/ActionController.php index 205dcc7de3d..08f9a601295 100644 --- a/src/Http/Controllers/CP/ActionController.php +++ b/src/Http/Controllers/CP/ActionController.php @@ -4,6 +4,7 @@ use Exception; use Illuminate\Http\Request; +use Statamic\Exceptions\AssetConflictException; use Statamic\Facades\Action; use Statamic\Facades\User; use Statamic\Support\Arr; @@ -45,7 +46,13 @@ public function run(Request $request) try { $response = $action->run($items, $values); } catch (Exception $e) { - $response = empty($msg = $e->getMessage()) ? __('Action failed') : $msg; + if ($e instanceof AssetConflictException) { + $response = array_merge([ + 'message' => $e->getMessage(), + ], $e->context()); + } else { + $response = empty($msg = $e->getMessage()) ? __('Action failed') : $msg; + } $successful = false; } diff --git a/src/Http/Controllers/CP/Assets/AssetsController.php b/src/Http/Controllers/CP/Assets/AssetsController.php index a4bb8b4ffa0..55c635e065f 100644 --- a/src/Http/Controllers/CP/Assets/AssetsController.php +++ b/src/Http/Controllers/CP/Assets/AssetsController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\CP\Assets; use Facades\Statamic\Fields\Validator as FieldValidator; +use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; @@ -120,7 +121,20 @@ public function store(Request $request) try { $validator->validate(); } catch (ValidationException $e) { - throw $e->status(409); + $existingAsset = $container->asset($path); + + throw new HttpResponseException(response()->json([ + 'message' => $e->getMessage(), + 'errors' => $e->errors(), + 'conflict' => [ + 'type' => 'asset_upload', + 'filename' => $basename, + 'existing' => [ + 'preview' => $existingAsset ? ($existingAsset->container()->accessible() ? $existingAsset->url() : $existingAsset->thumbnailUrl()) : null, + 'thumbnail' => $existingAsset?->thumbnailUrl('small'), + ], + ], + ], 409)); } } diff --git a/src/Listeners/ClearAssetGlideCache.php b/src/Listeners/ClearAssetGlideCache.php index 36bdeac8820..e90e20c5776 100644 --- a/src/Listeners/ClearAssetGlideCache.php +++ b/src/Listeners/ClearAssetGlideCache.php @@ -7,6 +7,7 @@ use Statamic\Events\AssetDeleted; use Statamic\Events\AssetReuploaded; use Statamic\Events\AssetSaved; +use Statamic\Events\AssetUploaded; use Statamic\Events\Subscriber; use Statamic\Facades\Glide; use Statamic\Imaging\PresetGenerator; @@ -22,6 +23,7 @@ class ClearAssetGlideCache extends Subscriber implements ShouldQueue AssetSaved::class => 'handleSaved', AssetDeleted::class => 'handleDeleted', AssetReuploaded::class => 'handleReuploaded', + AssetUploaded::class => 'handleUploaded', ]; public function __construct(PresetGenerator $generator) @@ -34,6 +36,11 @@ public function handleReuploaded(AssetReuploaded $event) $this->clear($event->asset); } + public function handleUploaded(AssetUploaded $event) + { + $this->clear($event->asset); + } + public function handleDeleted(AssetDeleted $event) { $this->clear($event->asset); diff --git a/tests/Actions/MoveAssetTest.php b/tests/Actions/MoveAssetTest.php new file mode 100644 index 00000000000..996daf705b8 --- /dev/null +++ b/tests/Actions/MoveAssetTest.php @@ -0,0 +1,118 @@ +container = tap( + (new AssetContainer)->handle('test_container')->disk('test') + )->save(); + } + + private function createAsset(string $path, string $contents = 'contents'): void + { + Storage::disk('test')->put($path, $contents); + $this->container->makeAsset($path)->save(); + } + + private function move(string $path, string $folder, ?string $strategy = null) + { + $context = ['container' => 'test_container']; + + if ($strategy) { + $context['conflict'] = $strategy; + } + + return $this->post(cp_route('assets.actions.run'), [ + 'action' => 'move_asset', + 'context' => $context, + 'selections' => ['test_container::'.$path], + 'values' => ['folder' => $folder], + ]); + } + + #[Test] + public function it_blocks_conflicting_move_without_strategy(): void + { + $this->createAsset('source/logo.svg', 'new'); + $this->createAsset('target/logo.svg', 'existing'); + + $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->move('source/logo.svg', 'target') + ->assertOk() + ->assertJson([ + 'success' => false, + 'conflict' => [ + 'type' => 'asset_move', + 'destination' => 'target', + ], + ]); + + Storage::disk('test')->assertExists('source/logo.svg'); + Storage::disk('test')->assertExists('target/logo.svg'); + $this->assertEquals('existing', Storage::disk('test')->get('target/logo.svg')); + } + + #[Test] + public function it_can_overwrite_conflicting_move_when_strategy_is_overwrite(): void + { + $this->createAsset('source/logo.svg', 'new'); + $this->createAsset('target/logo.svg', 'existing'); + + $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->move('source/logo.svg', 'target', 'overwrite') + ->assertOk() + ->assertJson([ + 'success' => true, + ]); + + Storage::disk('test')->assertMissing('source/logo.svg'); + Storage::disk('test')->assertExists('target/logo.svg'); + $this->assertEquals('new', Storage::disk('test')->get('target/logo.svg')); + } + + #[Test] + public function it_can_keep_both_with_timestamp_strategy(): void + { + Carbon::setTestNow(Carbon::createFromTimestamp(1712000000, config('app.timezone'))); + + $this->createAsset('source/logo.svg', 'new'); + $this->createAsset('target/logo.svg', 'existing'); + + $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->move('source/logo.svg', 'target', 'timestamp') + ->assertOk() + ->assertJson([ + 'success' => true, + ]); + + Storage::disk('test')->assertMissing('source/logo.svg'); + Storage::disk('test')->assertExists('target/logo.svg'); + Storage::disk('test')->assertExists('target/logo-1712000000.svg'); + $this->assertEquals('existing', Storage::disk('test')->get('target/logo.svg')); + $this->assertEquals('new', Storage::disk('test')->get('target/logo-1712000000.svg')); + } +} From 44b2393f0ec8a81a6fe329287fec1ebe5a629740 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Fri, 3 Apr 2026 16:29:44 -0400 Subject: [PATCH 02/16] fix inverted age logic --- src/Actions/MoveAsset.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Actions/MoveAsset.php b/src/Actions/MoveAsset.php index c8b52713e5f..9251d805618 100644 --- a/src/Actions/MoveAsset.php +++ b/src/Actions/MoveAsset.php @@ -60,8 +60,8 @@ public function run($assets, $values) ? __('statamic::messages.asset_conflict_newer') : __('statamic::messages.asset_conflict_older'); $existingDescriptor = $sourceLastModified >= $destinationLastModified - ? __('statamic::messages.asset_conflict_a_newer') - : __('statamic::messages.asset_conflict_an_older'); + ? __('statamic::messages.asset_conflict_an_older') + : __('statamic::messages.asset_conflict_a_newer'); $existingAge = $sourceLastModified >= $destinationLastModified ? __('statamic::messages.asset_conflict_older') : __('statamic::messages.asset_conflict_newer'); From fa2fa582c3192f2c85680a5ac6adea23f6b07fbe Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Fri, 3 Apr 2026 16:30:51 -0400 Subject: [PATCH 03/16] Move conflict strategy leaks to all subsequent conflicts --- resources/js/components/assets/Browser/Browser.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/components/assets/Browser/Browser.vue b/resources/js/components/assets/Browser/Browser.vue index fdfdf0f8429..52192cb1cb5 100644 --- a/resources/js/components/assets/Browser/Browser.vue +++ b/resources/js/components/assets/Browser/Browser.vue @@ -1017,7 +1017,7 @@ export default { async continueMoveConflictResolution(context, strategy = null) { const conflictAssetId = context.conflict?.asset?.id; - const resolution = strategy ?? this.moveConflictPolicy; + const resolution = this.moveConflictPolicy; if (conflictAssetId) { context.pendingSelections = context.pendingSelections.filter((id) => id !== conflictAssetId); From 1826764016248aea26c38e401b5c50b242014a2a Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Fri, 3 Apr 2026 17:18:57 -0400 Subject: [PATCH 04/16] Fix asset conflict modal flow and expand move conflict coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This updates the asset conflict UX by making modal-dismiss behavior resolve as cancel, only showing “apply to all” when multiple upload conflicts exist, and replacing recursive move-conflict continuation with an iterative loop. It also adds MoveAsset tests for non-conflicting moves, same-folder no-op moves, and explicit cancel strategy handling. --- .../js/components/assets/Browser/Browser.vue | 88 +++++++++++++------ tests/Actions/MoveAssetTest.php | 60 +++++++++++++ 2 files changed, 122 insertions(+), 26 deletions(-) diff --git a/resources/js/components/assets/Browser/Browser.vue b/resources/js/components/assets/Browser/Browser.vue index 52192cb1cb5..6187e0349a3 100644 --- a/resources/js/components/assets/Browser/Browser.vue +++ b/resources/js/components/assets/Browser/Browser.vue @@ -185,7 +185,7 @@

{{ moveConflictMessage }}

@@ -209,13 +209,14 @@

{{ uploadConflictMessage }}