Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/SharpFM/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@ public override void OnFrameworkInitializationCompleted()
Services = services.BuildServiceProvider();

var inputPrompt = new WindowInputPrompt(desktop.MainWindow);
var collisionPrompt = new WindowClipCollisionPrompt(desktop.MainWindow);
var viewModel = new MainWindowViewModel(
logger,
Services.GetRequiredService<IClipboardService>(),
Services.GetRequiredService<IFolderService>(),
inputPrompt);
inputPrompt,
collisionPrompt);

// Load plugins
var pluginHost = new PluginHost(viewModel, loggerFactory, inputPrompt);
Expand Down
57 changes: 57 additions & 0 deletions src/SharpFM/Dialogs/ClipCollisionDialog.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<Window
x:Class="SharpFM.Dialogs.ClipCollisionDialog"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Clip already exists"
Width="460"
SizeToContent="Height"
WindowStartupLocation="CenterOwner">

<Grid Margin="16" RowDefinitions="Auto,Auto,Auto,Auto">

<TextBlock
x:Name="messageLabel"
Grid.Row="0"
Margin="0,0,0,8"
Classes="Fluent2Body"
Text=""
TextWrapping="Wrap" />

<TextBlock
x:Name="locationLabel"
Grid.Row="1"
Margin="0,0,0,12"
Classes="Fluent2Body"
FontStyle="Italic"
Text=""
TextWrapping="Wrap" />

<CheckBox
x:Name="applyToAllBox"
Grid.Row="2"
Margin="0,0,0,12"
Content="Apply to all remaining clips in this paste" />

<StackPanel
Grid.Row="3"
HorizontalAlignment="Right"
Orientation="Horizontal"
Spacing="8">
<Button
x:Name="cancelButton"
Classes="Fluent2"
Content="Cancel" />
<Button
x:Name="keepBothButton"
Classes="Fluent2"
Content="Keep both" />
<Button
x:Name="replaceButton"
Classes="Fluent2Primary"
Content="Replace"
IsDefault="True" />
</StackPanel>

</Grid>

</Window>
60 changes: 60 additions & 0 deletions src/SharpFM/Dialogs/ClipCollisionDialog.axaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;

namespace SharpFM.Dialogs;

/// <summary>
/// Modal that asks the user how to resolve a paste collision (same name,
/// different XML). Production <see cref="IClipCollisionPrompt"/> opens this
/// over the main window; tests use a fake prompt and never construct this.
/// </summary>
[ExcludeFromCodeCoverage]
public partial class ClipCollisionDialog : Window
{
private readonly TextBlock _messageLabel;
private readonly TextBlock _locationLabel;
private readonly CheckBox _applyToAllBox;

public ClipCollisionDecision Result { get; private set; } =
new(ClipCollisionChoice.Cancel, ApplyToAll: false);

public ClipCollisionDialog()
{
AvaloniaXamlLoader.Load(this);
_messageLabel = this.FindControl<TextBlock>("messageLabel")!;
_locationLabel = this.FindControl<TextBlock>("locationLabel")!;
_applyToAllBox = this.FindControl<CheckBox>("applyToAllBox")!;

this.FindControl<Button>("replaceButton")!.Click += (_, _) => Finish(ClipCollisionChoice.Replace);
this.FindControl<Button>("keepBothButton")!.Click += (_, _) => Finish(ClipCollisionChoice.KeepBoth);
this.FindControl<Button>("cancelButton")!.Click += (_, _) => Finish(ClipCollisionChoice.Cancel);
}

private void Finish(ClipCollisionChoice choice)
{
Result = new ClipCollisionDecision(choice, _applyToAllBox.IsChecked == true);
Close();
}

public void Configure(string clipName, IReadOnlyList<string> folderPath)
{
_messageLabel.Text = $"A clip named \"{clipName}\" already exists with different content.";
_locationLabel.Text = folderPath.Count == 0
? "Location: (root)"
: $"Location: {string.Join(" / ", folderPath)}";
}

public static async Task<ClipCollisionDecision> PromptAsync(
Window owner,
string clipName,
IReadOnlyList<string> folderPath)
{
var window = new ClipCollisionDialog();
window.Configure(clipName, folderPath);
await window.ShowDialog(owner);
return window.Result;
}
}
30 changes: 30 additions & 0 deletions src/SharpFM/Dialogs/IClipCollisionPrompt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Threading.Tasks;

namespace SharpFM.Dialogs;

/// <summary>How the user wants to resolve a same-name + different-content paste collision.</summary>
public enum ClipCollisionChoice
{
/// <summary>Skip the incoming clip and abort the remainder of the paste batch.</summary>
Cancel,
/// <summary>Overwrite the existing clip with the incoming XML.</summary>
Replace,
/// <summary>Add the incoming clip alongside the existing one with a unique suffix.</summary>
KeepBoth,
}

/// <summary>Result of <see cref="IClipCollisionPrompt.PromptAsync"/>.</summary>
/// <param name="Choice">The action selected by the user.</param>
/// <param name="ApplyToAll">When true, reuse this choice for the remainder of the paste batch.</param>
public sealed record ClipCollisionDecision(ClipCollisionChoice Choice, bool ApplyToAll);

/// <summary>
/// Host-supplied prompt for resolving paste collisions where a clip with the
/// same name already exists in the target folder but holds different XML.
/// Mirrors <see cref="IInputPrompt"/>'s test-friendly abstraction.
/// </summary>
public interface IClipCollisionPrompt
{
Task<ClipCollisionDecision> PromptAsync(string clipName, IReadOnlyList<string> folderPath);
}
17 changes: 17 additions & 0 deletions src/SharpFM/Dialogs/NullClipCollisionPrompt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Threading.Tasks;

namespace SharpFM.Dialogs;

/// <summary>
/// Default <see cref="IClipCollisionPrompt"/> for environments without a UI
/// thread (headless tests, legacy view-model ctor path). Returns
/// <see cref="ClipCollisionChoice.Cancel"/> so a collision in a non-interactive
/// context aborts the rest of the paste instead of silently mutating existing
/// clips.
/// </summary>
public sealed class NullClipCollisionPrompt : IClipCollisionPrompt
{
public Task<ClipCollisionDecision> PromptAsync(string clipName, IReadOnlyList<string> folderPath) =>
Task.FromResult(new ClipCollisionDecision(ClipCollisionChoice.Cancel, ApplyToAll: false));
}
17 changes: 17 additions & 0 deletions src/SharpFM/Dialogs/WindowClipCollisionPrompt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Avalonia.Controls;

namespace SharpFM.Dialogs;

/// <summary>
/// Production <see cref="IClipCollisionPrompt"/>. Opens a
/// <see cref="ClipCollisionDialog"/> modal over the supplied owner window.
/// </summary>
[ExcludeFromCodeCoverage]
public sealed class WindowClipCollisionPrompt(Window owner) : IClipCollisionPrompt
{
public Task<ClipCollisionDecision> PromptAsync(string clipName, IReadOnlyList<string> folderPath) =>
ClipCollisionDialog.PromptAsync(owner, clipName, folderPath);
}
90 changes: 67 additions & 23 deletions src/SharpFM/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public partial class MainWindowViewModel : INotifyPropertyChanged
private readonly IClipboardService _clipboard;
private readonly IFolderService _folderService;
private readonly IInputPrompt _prompt;
private readonly IClipCollisionPrompt _collisionPrompt;
private readonly DispatcherTimer _statusTimer;
private IClipRepository _repository;
private OpenTabViewModel? _trackedActiveTab;
Expand Down Expand Up @@ -83,12 +84,14 @@ public MainWindowViewModel(
ILogger logger,
IClipboardService clipboard,
IFolderService folderService,
IInputPrompt? prompt = null)
IInputPrompt? prompt = null,
IClipCollisionPrompt? collisionPrompt = null)
{
_logger = logger;
_clipboard = clipboard;
_folderService = folderService;
_prompt = prompt ?? new NullInputPrompt();
_collisionPrompt = collisionPrompt ?? new NullClipCollisionPrompt();

_statusTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
_statusTimer.Tick += (_, _) =>
Expand Down Expand Up @@ -448,22 +451,25 @@ public async Task PasteFileMakerClipData()
(ClipTypeRegistry.IsRegistered(format) ? recognized : unrecognized).Add(format);
}

var batch = new PasteBatchState();

foreach (var format in recognized)
{
var (added, last) = await PasteOneFormat(format, pasteRoot);
var (added, last) = await PasteOneFormat(format, pasteRoot, batch);
lastAdded = last ?? lastAdded;
count += added;
if (batch.Cancelled) break;
}

// Fall back to opaque only when no recognized format produced a
// paste — same payload often arrives in both a known and a
// not-yet-registered encoding, and the known one already covered it.
string? unknownFormatPasted = null;
if (count == 0)
if (count == 0 && !batch.Cancelled)
{
foreach (var format in unrecognized)
{
var (added, last) = await PasteOneFormat(format, pasteRoot);
var (added, last) = await PasteOneFormat(format, pasteRoot, batch);
if (added == 0) continue;
lastAdded = last ?? lastAdded;
count += added;
Expand All @@ -484,6 +490,10 @@ public async Task PasteFileMakerClipData()
{
ShowStatus($"Pasted unknown format {unknownFormatPasted}; will round-trip as raw XML.");
}
else if (batch.Cancelled)
{
ShowStatus($"Paste cancelled at name collision; kept {count} clip(s)", isError: true);
}
else
{
ShowStatus(count > 0 ? $"Pasted {count} clip(s) from FileMaker" : "No FileMaker clips found on clipboard");
Expand All @@ -496,7 +506,10 @@ public async Task PasteFileMakerClipData()
}
}

private async Task<(int added, ClipViewModel? last)> PasteOneFormat(string format, IReadOnlyList<string> pasteRoot)
private async Task<(int added, ClipViewModel? last)> PasteOneFormat(
string format,
IReadOnlyList<string> pasteRoot,
PasteBatchState batch)
{
object? clipData = await _clipboard.GetDataAsync(format);
if (clipData is not byte[] dataObj) return (0, null);
Expand All @@ -516,33 +529,64 @@ public async Task PasteFileMakerClipData()

foreach (var entry in decomposed.Entries)
{
var entryClip = Clip.FromXml("new-clip", format, entry.Xml);
if (FileMakerClips.Any(k => k.Clip.Xml == entryClip.Xml &&
FolderPathsEqual(k.FolderPath, Combine(pasteRoot, entry.FolderPath))))
{
continue;
}

if (batch.Cancelled) break;
var folderPath = Combine(pasteRoot, entry.FolderPath);
entryClip = entryClip.Rename(UniqueClipName(entry.Name, folderPath));

last = new ClipViewModel(entryClip) { FolderPath = folderPath };
FileMakerClips.Add(last);
var entryClip = Clip.FromXml(entry.Name, format, entry.Xml);
var result = await TryPasteEntry(entry.Name, folderPath, entryClip, batch);
if (result is null) continue;
last = result;
added++;
}
return (added, last);
}

// don't add duplicates
if (FileMakerClips.Any(k => k.Clip.Xml == rawClip.Xml)) return (0, null);

var sourceName = ClipTypeRegistry.For(format).TryGetSourceName(rawClip.Xml);
var desired = string.IsNullOrWhiteSpace(sourceName) ? "new-clip" : sourceName;
var singleClip = rawClip.Rename(UniqueClipName(desired, pasteRoot));
var single = await TryPasteEntry(desired, pasteRoot, rawClip, batch);
return single is null ? (0, null) : (1, single);
}

private async Task<ClipViewModel?> TryPasteEntry(
string name,
IReadOnlyList<string> folderPath,
Clip clip,
PasteBatchState batch)
{
var existing = FileMakerClips.FirstOrDefault(c =>
c.Clip.Name == name && FolderPathsEqual(c.FolderPath, folderPath));

last = new ClipViewModel(singleClip) { FolderPath = pasteRoot };
FileMakerClips.Add(last);
return (1, last);
if (existing is not null)
{
if (existing.Clip.Xml == clip.Xml) return null;

var decision = batch.StickyDecision
?? await _collisionPrompt.PromptAsync(name, folderPath);
if (decision.ApplyToAll) batch.StickyDecision = decision;

switch (decision.Choice)
{
case ClipCollisionChoice.Cancel:
batch.Cancelled = true;
return null;
case ClipCollisionChoice.Replace:
existing.Replace(clip.Xml);
return existing;
case ClipCollisionChoice.KeepBoth:
// fall through to the rename-and-add path below
break;
}
}

var renamed = clip.Rename(UniqueClipName(name, folderPath));
var added = new ClipViewModel(renamed) { FolderPath = folderPath };
FileMakerClips.Add(added);
return added;
}

private sealed class PasteBatchState
{
public ClipCollisionDecision? StickyDecision { get; set; }
public bool Cancelled { get; set; }
}

private static IReadOnlyList<string> Combine(IReadOnlyList<string> root, IReadOnlyList<string> sub)
Expand Down
Loading
Loading