Skip to content

Commit a0bee31

Browse files
committed
fix: make paste deduplication name-aware
Paste collisions used to be content-only: pasting a clip whose XML matched any existing clip was silently skipped, regardless of name. This broke variant tracking (paste, rename, repaste leaves the user with only the renamed copy). Dedup now keys on (name, folder). Same name + same content skips as a true duplicate. Different name + same content lands as a variant. Same name + different content prompts the user via a new IClipCollisionPrompt with Replace / Keep both / Cancel choices and an apply-to-all option for batch (group) pastes.
1 parent d6e2aff commit a0bee31

8 files changed

Lines changed: 407 additions & 27 deletions

File tree

src/SharpFM/App.axaml.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,13 @@ public override void OnFrameworkInitializationCompleted()
4343
Services = services.BuildServiceProvider();
4444

4545
var inputPrompt = new WindowInputPrompt(desktop.MainWindow);
46+
var collisionPrompt = new WindowClipCollisionPrompt(desktop.MainWindow);
4647
var viewModel = new MainWindowViewModel(
4748
logger,
4849
Services.GetRequiredService<IClipboardService>(),
4950
Services.GetRequiredService<IFolderService>(),
50-
inputPrompt);
51+
inputPrompt,
52+
collisionPrompt);
5153

5254
// Load plugins
5355
var pluginHost = new PluginHost(viewModel, loggerFactory, inputPrompt);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<Window
2+
x:Class="SharpFM.Dialogs.ClipCollisionDialog"
3+
xmlns="https://github.com/avaloniaui"
4+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
5+
Title="Clip already exists"
6+
Width="460"
7+
SizeToContent="Height"
8+
WindowStartupLocation="CenterOwner">
9+
10+
<Grid Margin="16" RowDefinitions="Auto,Auto,Auto,Auto">
11+
12+
<TextBlock
13+
x:Name="messageLabel"
14+
Grid.Row="0"
15+
Margin="0,0,0,8"
16+
Classes="Fluent2Body"
17+
Text=""
18+
TextWrapping="Wrap" />
19+
20+
<TextBlock
21+
x:Name="locationLabel"
22+
Grid.Row="1"
23+
Margin="0,0,0,12"
24+
Classes="Fluent2Body"
25+
FontStyle="Italic"
26+
Text=""
27+
TextWrapping="Wrap" />
28+
29+
<CheckBox
30+
x:Name="applyToAllBox"
31+
Grid.Row="2"
32+
Margin="0,0,0,12"
33+
Content="Apply to all remaining clips in this paste" />
34+
35+
<StackPanel
36+
Grid.Row="3"
37+
HorizontalAlignment="Right"
38+
Orientation="Horizontal"
39+
Spacing="8">
40+
<Button
41+
x:Name="cancelButton"
42+
Classes="Fluent2"
43+
Content="Cancel" />
44+
<Button
45+
x:Name="keepBothButton"
46+
Classes="Fluent2"
47+
Content="Keep both" />
48+
<Button
49+
x:Name="replaceButton"
50+
Classes="Fluent2Primary"
51+
Content="Replace"
52+
IsDefault="True" />
53+
</StackPanel>
54+
55+
</Grid>
56+
57+
</Window>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using System.Collections.Generic;
2+
using System.Diagnostics.CodeAnalysis;
3+
using System.Threading.Tasks;
4+
using Avalonia.Controls;
5+
using Avalonia.Markup.Xaml;
6+
7+
namespace SharpFM.Dialogs;
8+
9+
/// <summary>
10+
/// Modal that asks the user how to resolve a paste collision (same name,
11+
/// different XML). Production <see cref="IClipCollisionPrompt"/> opens this
12+
/// over the main window; tests use a fake prompt and never construct this.
13+
/// </summary>
14+
[ExcludeFromCodeCoverage]
15+
public partial class ClipCollisionDialog : Window
16+
{
17+
private readonly TextBlock _messageLabel;
18+
private readonly TextBlock _locationLabel;
19+
private readonly CheckBox _applyToAllBox;
20+
21+
public ClipCollisionDecision Result { get; private set; } =
22+
new(ClipCollisionChoice.Cancel, ApplyToAll: false);
23+
24+
public ClipCollisionDialog()
25+
{
26+
AvaloniaXamlLoader.Load(this);
27+
_messageLabel = this.FindControl<TextBlock>("messageLabel")!;
28+
_locationLabel = this.FindControl<TextBlock>("locationLabel")!;
29+
_applyToAllBox = this.FindControl<CheckBox>("applyToAllBox")!;
30+
31+
this.FindControl<Button>("replaceButton")!.Click += (_, _) => Finish(ClipCollisionChoice.Replace);
32+
this.FindControl<Button>("keepBothButton")!.Click += (_, _) => Finish(ClipCollisionChoice.KeepBoth);
33+
this.FindControl<Button>("cancelButton")!.Click += (_, _) => Finish(ClipCollisionChoice.Cancel);
34+
}
35+
36+
private void Finish(ClipCollisionChoice choice)
37+
{
38+
Result = new ClipCollisionDecision(choice, _applyToAllBox.IsChecked == true);
39+
Close();
40+
}
41+
42+
public void Configure(string clipName, IReadOnlyList<string> folderPath)
43+
{
44+
_messageLabel.Text = $"A clip named \"{clipName}\" already exists with different content.";
45+
_locationLabel.Text = folderPath.Count == 0
46+
? "Location: (root)"
47+
: $"Location: {string.Join(" / ", folderPath)}";
48+
}
49+
50+
public static async Task<ClipCollisionDecision> PromptAsync(
51+
Window owner,
52+
string clipName,
53+
IReadOnlyList<string> folderPath)
54+
{
55+
var window = new ClipCollisionDialog();
56+
window.Configure(clipName, folderPath);
57+
await window.ShowDialog(owner);
58+
return window.Result;
59+
}
60+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System.Collections.Generic;
2+
using System.Threading.Tasks;
3+
4+
namespace SharpFM.Dialogs;
5+
6+
/// <summary>How the user wants to resolve a same-name + different-content paste collision.</summary>
7+
public enum ClipCollisionChoice
8+
{
9+
/// <summary>Skip the incoming clip and abort the remainder of the paste batch.</summary>
10+
Cancel,
11+
/// <summary>Overwrite the existing clip with the incoming XML.</summary>
12+
Replace,
13+
/// <summary>Add the incoming clip alongside the existing one with a unique suffix.</summary>
14+
KeepBoth,
15+
}
16+
17+
/// <summary>Result of <see cref="IClipCollisionPrompt.PromptAsync"/>.</summary>
18+
/// <param name="Choice">The action selected by the user.</param>
19+
/// <param name="ApplyToAll">When true, reuse this choice for the remainder of the paste batch.</param>
20+
public sealed record ClipCollisionDecision(ClipCollisionChoice Choice, bool ApplyToAll);
21+
22+
/// <summary>
23+
/// Host-supplied prompt for resolving paste collisions where a clip with the
24+
/// same name already exists in the target folder but holds different XML.
25+
/// Mirrors <see cref="IInputPrompt"/>'s test-friendly abstraction.
26+
/// </summary>
27+
public interface IClipCollisionPrompt
28+
{
29+
Task<ClipCollisionDecision> PromptAsync(string clipName, IReadOnlyList<string> folderPath);
30+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using System.Collections.Generic;
2+
using System.Threading.Tasks;
3+
4+
namespace SharpFM.Dialogs;
5+
6+
/// <summary>
7+
/// Default <see cref="IClipCollisionPrompt"/> for environments without a UI
8+
/// thread (headless tests, legacy view-model ctor path). Returns
9+
/// <see cref="ClipCollisionChoice.Cancel"/> so a collision in a non-interactive
10+
/// context aborts the rest of the paste instead of silently mutating existing
11+
/// clips.
12+
/// </summary>
13+
public sealed class NullClipCollisionPrompt : IClipCollisionPrompt
14+
{
15+
public Task<ClipCollisionDecision> PromptAsync(string clipName, IReadOnlyList<string> folderPath) =>
16+
Task.FromResult(new ClipCollisionDecision(ClipCollisionChoice.Cancel, ApplyToAll: false));
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using System.Collections.Generic;
2+
using System.Diagnostics.CodeAnalysis;
3+
using System.Threading.Tasks;
4+
using Avalonia.Controls;
5+
6+
namespace SharpFM.Dialogs;
7+
8+
/// <summary>
9+
/// Production <see cref="IClipCollisionPrompt"/>. Opens a
10+
/// <see cref="ClipCollisionDialog"/> modal over the supplied owner window.
11+
/// </summary>
12+
[ExcludeFromCodeCoverage]
13+
public sealed class WindowClipCollisionPrompt(Window owner) : IClipCollisionPrompt
14+
{
15+
public Task<ClipCollisionDecision> PromptAsync(string clipName, IReadOnlyList<string> folderPath) =>
16+
ClipCollisionDialog.PromptAsync(owner, clipName, folderPath);
17+
}

src/SharpFM/ViewModels/MainWindowViewModel.cs

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)