@@ -28,6 +28,7 @@ public partial class MainWindowViewModel : INotifyPropertyChanged
2828 private readonly IClipboardService _clipboard ;
2929 private readonly IFolderService _folderService ;
3030 private readonly IInputPrompt _prompt ;
31+ private readonly IClipCollisionPrompt _collisionPrompt ;
3132 private readonly DispatcherTimer _statusTimer ;
3233 private IClipRepository _repository ;
3334 private OpenTabViewModel ? _trackedActiveTab ;
@@ -83,12 +84,14 @@ public MainWindowViewModel(
8384 ILogger logger ,
8485 IClipboardService clipboard ,
8586 IFolderService folderService ,
86- IInputPrompt ? prompt = null )
87+ IInputPrompt ? prompt = null ,
88+ IClipCollisionPrompt ? collisionPrompt = null )
8789 {
8890 _logger = logger ;
8991 _clipboard = clipboard ;
9092 _folderService = folderService ;
9193 _prompt = prompt ?? new NullInputPrompt ( ) ;
94+ _collisionPrompt = collisionPrompt ?? new NullClipCollisionPrompt ( ) ;
9295
9396 _statusTimer = new DispatcherTimer { Interval = TimeSpan . FromSeconds ( 3 ) } ;
9497 _statusTimer . Tick += ( _ , _ ) =>
@@ -448,22 +451,25 @@ public async Task PasteFileMakerClipData()
448451 ( ClipTypeRegistry . IsRegistered ( format ) ? recognized : unrecognized ) . Add ( format ) ;
449452 }
450453
454+ var batch = new PasteBatchState ( ) ;
455+
451456 foreach ( var format in recognized )
452457 {
453- var ( added , last ) = await PasteOneFormat ( format , pasteRoot ) ;
458+ var ( added , last ) = await PasteOneFormat ( format , pasteRoot , batch ) ;
454459 lastAdded = last ?? lastAdded ;
455460 count += added ;
461+ if ( batch . Cancelled ) break ;
456462 }
457463
458464 // Fall back to opaque only when no recognized format produced a
459465 // paste — same payload often arrives in both a known and a
460466 // not-yet-registered encoding, and the known one already covered it.
461467 string ? unknownFormatPasted = null ;
462- if ( count == 0 )
468+ if ( count == 0 && ! batch . Cancelled )
463469 {
464470 foreach ( var format in unrecognized )
465471 {
466- var ( added , last ) = await PasteOneFormat ( format , pasteRoot ) ;
472+ var ( added , last ) = await PasteOneFormat ( format , pasteRoot , batch ) ;
467473 if ( added == 0 ) continue ;
468474 lastAdded = last ?? lastAdded ;
469475 count += added ;
@@ -484,6 +490,10 @@ public async Task PasteFileMakerClipData()
484490 {
485491 ShowStatus ( $ "Pasted unknown format { unknownFormatPasted } ; will round-trip as raw XML.") ;
486492 }
493+ else if ( batch . Cancelled )
494+ {
495+ ShowStatus ( $ "Paste cancelled at name collision; kept { count } clip(s)", isError : true ) ;
496+ }
487497 else
488498 {
489499 ShowStatus ( count > 0 ? $ "Pasted { count } clip(s) from FileMaker" : "No FileMaker clips found on clipboard" ) ;
@@ -496,7 +506,10 @@ public async Task PasteFileMakerClipData()
496506 }
497507 }
498508
499- private async Task < ( int added , ClipViewModel ? last ) > PasteOneFormat ( string format , IReadOnlyList < string > pasteRoot )
509+ private async Task < ( int added , ClipViewModel ? last ) > PasteOneFormat (
510+ string format ,
511+ IReadOnlyList < string > pasteRoot ,
512+ PasteBatchState batch )
500513 {
501514 object ? clipData = await _clipboard . GetDataAsync ( format ) ;
502515 if ( clipData is not byte [ ] dataObj ) return ( 0 , null ) ;
@@ -516,33 +529,64 @@ public async Task PasteFileMakerClipData()
516529
517530 foreach ( var entry in decomposed . Entries )
518531 {
519- var entryClip = Clip . FromXml ( "new-clip" , format , entry . Xml ) ;
520- if ( FileMakerClips . Any ( k => k . Clip . Xml == entryClip . Xml &&
521- FolderPathsEqual ( k . FolderPath , Combine ( pasteRoot , entry . FolderPath ) ) ) )
522- {
523- continue ;
524- }
525-
532+ if ( batch . Cancelled ) break ;
526533 var folderPath = Combine ( pasteRoot , entry . FolderPath ) ;
527- entryClip = entryClip . Rename ( UniqueClipName ( entry . Name , folderPath ) ) ;
528-
529- last = new ClipViewModel ( entryClip ) { FolderPath = folderPath } ;
530- FileMakerClips . Add ( last ) ;
534+ var entryClip = Clip . FromXml ( entry . Name , format , entry . Xml ) ;
535+ var result = await TryPasteEntry ( entry . Name , folderPath , entryClip , batch ) ;
536+ if ( result is null ) continue ;
537+ last = result ;
531538 added ++ ;
532539 }
533540 return ( added , last ) ;
534541 }
535542
536- // don't add duplicates
537- if ( FileMakerClips . Any ( k => k . Clip . Xml == rawClip . Xml ) ) return ( 0 , null ) ;
538-
539543 var sourceName = ClipTypeRegistry . For ( format ) . TryGetSourceName ( rawClip . Xml ) ;
540544 var desired = string . IsNullOrWhiteSpace ( sourceName ) ? "new-clip" : sourceName ;
541- var singleClip = rawClip . Rename ( UniqueClipName ( desired , pasteRoot ) ) ;
545+ var single = await TryPasteEntry ( desired , pasteRoot , rawClip , batch ) ;
546+ return single is null ? ( 0 , null ) : ( 1 , single ) ;
547+ }
548+
549+ private async Task < ClipViewModel ? > TryPasteEntry (
550+ string name ,
551+ IReadOnlyList < string > folderPath ,
552+ Clip clip ,
553+ PasteBatchState batch )
554+ {
555+ var existing = FileMakerClips . FirstOrDefault ( c =>
556+ c . Clip . Name == name && FolderPathsEqual ( c . FolderPath , folderPath ) ) ;
542557
543- last = new ClipViewModel ( singleClip ) { FolderPath = pasteRoot } ;
544- FileMakerClips . Add ( last ) ;
545- return ( 1 , last ) ;
558+ if ( existing is not null )
559+ {
560+ if ( existing . Clip . Xml == clip . Xml ) return null ;
561+
562+ var decision = batch . StickyDecision
563+ ?? await _collisionPrompt . PromptAsync ( name , folderPath ) ;
564+ if ( decision . ApplyToAll ) batch . StickyDecision = decision ;
565+
566+ switch ( decision . Choice )
567+ {
568+ case ClipCollisionChoice . Cancel :
569+ batch . Cancelled = true ;
570+ return null ;
571+ case ClipCollisionChoice . Replace :
572+ existing . Replace ( clip . Xml ) ;
573+ return existing ;
574+ case ClipCollisionChoice . KeepBoth :
575+ // fall through to the rename-and-add path below
576+ break ;
577+ }
578+ }
579+
580+ var renamed = clip . Rename ( UniqueClipName ( name , folderPath ) ) ;
581+ var added = new ClipViewModel ( renamed ) { FolderPath = folderPath } ;
582+ FileMakerClips . Add ( added ) ;
583+ return added ;
584+ }
585+
586+ private sealed class PasteBatchState
587+ {
588+ public ClipCollisionDecision ? StickyDecision { get ; set ; }
589+ public bool Cancelled { get ; set ; }
546590 }
547591
548592 private static IReadOnlyList < string > Combine ( IReadOnlyList < string > root , IReadOnlyList < string > sub )
0 commit comments