@@ -117,12 +117,12 @@ protected virtual async Task MoveAsync(CancellationToken cancellationToken)
117117 if ( destinationViewModel is null || destinationViewModel . Inner . Id != destination . Id )
118118 return ;
119119
120- if ( items . Any ( item => destination . Id . Contains ( item . Inner . Id , StringComparison . InvariantCultureIgnoreCase ) ) )
120+ if ( items . Any ( item => IsAncestorOrSelf ( destination . Id , item . Inner . Id ) ) )
121121 return ;
122122
123- // Ensure the destination has content already loaded
123+ // Ensure the destination has content already loaded before collision checks
124124 if ( destinationViewModel . Items . IsEmpty ( ) )
125- _ = destinationViewModel . ListContentsAsync ( cts . Token ) ;
125+ await destinationViewModel . ListContentsAsync ( cts . Token ) ;
126126
127127 await transferViewModel . TransferAsync ( items . Select ( x => ( IStorableChild ) x . Inner ) , async ( item , reporter , token ) =>
128128 {
@@ -190,12 +190,12 @@ protected virtual async Task CopyAsync(CancellationToken cancellationToken)
190190 if ( destinationViewModel is null || destinationViewModel . Inner . Id != destination . Id )
191191 return ;
192192
193- if ( items . Any ( item => destination . Id . Contains ( item . Inner . Id , StringComparison . InvariantCultureIgnoreCase ) ) )
193+ if ( items . Any ( item => IsAncestorOrSelf ( destination . Id , item . Inner . Id ) ) )
194194 return ;
195195
196- // Ensure the destination has content already loaded
196+ // Ensure the destination has content already loaded before collision checks
197197 if ( destinationViewModel . Items . IsEmpty ( ) )
198- _ = destinationViewModel . ListContentsAsync ( cts . Token ) ;
198+ await destinationViewModel . ListContentsAsync ( cts . Token ) ;
199199
200200 await transferViewModel . TransferAsync ( items . Select ( x => x . Inner ) , async ( item , reporter , token ) =>
201201 {
@@ -205,10 +205,6 @@ await transferViewModel.TransferAsync(items.Select(x => x.Inner), async (item, r
205205 // Copy
206206 var copiedItem = await modifiableDestination . CreateCopyOfStorableAsync ( item , false , availableName , reporter , token ) ;
207207
208- // Ensure the destination has content already loaded
209- if ( destinationViewModel . Items . IsEmpty ( ) )
210- _ = destinationViewModel . ListContentsAsync ( cts . Token ) ;
211-
212208 // Add to destination
213209 destinationViewModel . Items . Insert ( copiedItem switch
214210 {
@@ -312,23 +308,24 @@ protected virtual async Task DeleteAsync(CancellationToken cancellationToken)
312308 if ( recycleBin is null )
313309 return ;
314310
315- var sizes = new List < long > ( ) ;
311+ // Key the sizes by item ID - the transfer callback receives the storable,
312+ // so positional lookups against the view model array would not match
313+ var sizes = new Dictionary < string , long > ( ) ;
316314 foreach ( var item in items )
317315 {
318- sizes . Add ( item . Inner switch
316+ sizes [ item . Inner . Id ] = item . Inner switch
319317 {
320318 IFile file => await file . GetSizeAsync ( cts . Token ) ?? 0L ,
321319 IFolder folder => await folder . GetSizeAsync ( cts . Token ) ?? 0L ,
322320 _ => 0L
323- } ) ;
321+ } ;
324322 }
325323
326324 if ( BrowserViewModel . Options . IsRecycleBinUnlimited ( ) )
327325 {
328326 await transferViewModel . TransferAsync ( items . Select ( x => ( IStorableChild ) x . Inner ) , async ( item , token ) =>
329327 {
330- var idx = Array . IndexOf ( items , item ) ;
331- await recyclableFolder . DeleteAsync ( item , sizes [ idx ] , false , token ) ;
328+ await recyclableFolder . DeleteAsync ( item , sizes . GetValueOrDefault ( item . Id , 0L ) , false , token ) ;
332329
333330 var itemToRemove = ParentFolder . Items . FirstOrDefault ( x => x . Inner . Id == item . Id ) ;
334331 if ( itemToRemove is not null )
@@ -339,7 +336,7 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner)
339336 {
340337 var occupiedSize = await recycleBin . GetSizeAsync ( cts . Token ) ;
341338 var availableSize = BrowserViewModel . Options . RecycleBinSize - occupiedSize ;
342- var permanently = availableSize < sizes . Sum ( ) ;
339+ var permanently = availableSize < sizes . Values . Sum ( ) ;
343340
344341 if ( permanently )
345342 {
@@ -356,16 +353,13 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner)
356353 return ;
357354 }
358355
359- var idx = 0 ;
360356 await transferViewModel . TransferAsync ( items . Select ( x => ( IStorableChild ) x . Inner ) , async ( item , token ) =>
361357 {
362- await recyclableFolder . DeleteAsync ( item , sizes [ idx ] , permanently , token ) ;
358+ await recyclableFolder . DeleteAsync ( item , sizes . GetValueOrDefault ( item . Id , 0L ) , permanently , token ) ;
363359
364360 var itemToRemove = ParentFolder . Items . FirstOrDefault ( x => x . Inner . Id == item . Id ) ;
365361 if ( itemToRemove is not null )
366362 ParentFolder . Items . RemoveAndGet ( itemToRemove ) ? . Dispose ( ) ;
367-
368- idx ++ ;
369363 } , cts . Token ) ;
370364 }
371365 }
@@ -410,7 +404,7 @@ await transferViewModel.TransferAsync(items.Select(x => (IStorableChild)x.Inner)
410404 [ RelayCommand ]
411405 protected virtual async Task ExportAsync ( CancellationToken cancellationToken )
412406 {
413- if ( ParentFolder ? . Folder is not IModifiableFolder parentModifiableFolder )
407+ if ( ParentFolder is null )
414408 return ;
415409
416410 if ( BrowserViewModel . TransferViewModel is not { IsProgressing : false } transferViewModel )
@@ -424,19 +418,56 @@ protected virtual async Task ExportAsync(CancellationToken cancellationToken)
424418 if ( destination is not IModifiableFolder destinationFolder )
425419 return ;
426420
427- transferViewModel . TransferType = TransferType . Move ;
428- using var cts = transferViewModel . GetCancellation ( cancellationToken ) ;
429- await transferViewModel . TransferAsync ( items . Select ( x => x . Inner ) , async ( item , reporter , token ) =>
430- {
431- // Copy and delete
432- await destinationFolder . CreateCopyOfStorableAsync ( item , false , reporter , token ) ;
433- await parentModifiableFolder . DeleteAsync ( ( IStorableChild ) item , token ) ;
421+ // Sources in read-only vaults cannot be removed - export becomes a copy
422+ var removeSource = ! BrowserViewModel . Options . IsReadOnly && ParentFolder . Folder is IModifiableFolder ;
434423
435- ParentFolder . Items . RemoveMatch ( x => x . Inner . Id == item . Id ) ? . Dispose ( ) ;
436- } , cts . Token ) ;
424+ try
425+ {
426+ transferViewModel . TransferType = removeSource ? TransferType . Move : TransferType . Copy ;
427+ using var cts = transferViewModel . GetCancellation ( cancellationToken ) ;
428+ await transferViewModel . TransferAsync ( items . Select ( x => x . Inner ) , async ( item , reporter , token ) =>
429+ {
430+ // Copy and delete
431+ await destinationFolder . CreateCopyOfStorableAsync ( item , false , reporter , token ) ;
432+ if ( removeSource && ParentFolder . Folder is IModifiableFolder parentModifiableFolder )
433+ {
434+ await parentModifiableFolder . DeleteAsync ( ( IStorableChild ) item , token ) ;
435+ ParentFolder . Items . RemoveMatch ( x => x . Inner . Id == item . Id ) ? . Dispose ( ) ;
436+ }
437+ } , cts . Token ) ;
438+ }
439+ catch ( Exception ex ) when ( ex is TaskCanceledException or OperationCanceledException )
440+ {
441+ _ = ex ;
442+ // TODO: Report error
443+ }
444+ finally
445+ {
446+ await transferViewModel . HideAsync ( ) ;
447+ }
437448 }
438449
439450 [ RelayCommand ]
440451 protected abstract Task OpenAsync ( CancellationToken cancellationToken ) ;
452+
453+ /// <summary>
454+ /// Determines whether <paramref name="destinationId"/> points to <paramref name="itemId"/> itself
455+ /// or to a descendant of it, respecting path segment boundaries.
456+ /// </summary>
457+ /// <remarks>
458+ /// A plain substring check would misclassify sibling paths that share a prefix (e.g. '/a/bc' and '/a/b').
459+ /// </remarks>
460+ private static bool IsAncestorOrSelf ( string destinationId , string itemId )
461+ {
462+ if ( destinationId . Equals ( itemId , StringComparison . OrdinalIgnoreCase ) )
463+ return true ;
464+
465+ if ( ! destinationId . StartsWith ( itemId , StringComparison . OrdinalIgnoreCase ) )
466+ return false ;
467+
468+ // The prefix must end exactly at a path separator boundary
469+ return itemId . EndsWith ( '/' ) || itemId . EndsWith ( '\\ ' )
470+ || destinationId [ itemId . Length ] is '/' or '\\ ' ;
471+ }
441472 }
442473}
0 commit comments