diff --git a/CopyPaste.Core/MyMConfig.cs b/CopyPaste.Core/MyMConfig.cs
index 85facf6..1f2a581 100644
--- a/CopyPaste.Core/MyMConfig.cs
+++ b/CopyPaste.Core/MyMConfig.cs
@@ -37,7 +37,13 @@ public sealed class MyMConfig
/// ID of the active theme (e.g., "copypaste.default").
/// Must match .
///
- public string ThemeId { get; set; } = "copypaste.default";
+ public string ThemeId { get; set; } = "copypaste.compact";
+
+ ///
+ /// Whether the user has already seen (and dismissed) the theme discovery hint in Settings.
+ /// Once true, the hint banner is never shown again.
+ ///
+ public bool HasSeenThemeHint { get; set; }
// ═══════════════════════════════════════════════════════════════
// Hotkey Configuration
diff --git a/CopyPaste.UI/App.xaml.cs b/CopyPaste.UI/App.xaml.cs
index 0a8161f..a3bb0fa 100644
--- a/CopyPaste.UI/App.xaml.cs
+++ b/CopyPaste.UI/App.xaml.cs
@@ -87,6 +87,7 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
InitializeCoreServices();
var config = _engine!.Config;
+ MigrateFirstRunToCompact(config);
L.Initialize(new LocalizationService(config.PreferredLanguage));
_themeRegistry = new ThemeRegistry();
@@ -113,6 +114,17 @@ protected override void OnLaunched(LaunchActivatedEventArgs args)
AppLogger.Info("Main window launched");
}
+ // Ensures fresh installs and users who never changed their theme get Compact by default.
+ // Only runs once (guarded by HasSeenThemeHint = false on first run).
+ private static void MigrateFirstRunToCompact(MyMConfig config)
+ {
+ if (!config.HasSeenThemeHint && config.ThemeId == "copypaste.default")
+ {
+ config.ThemeId = "copypaste.compact";
+ ConfigLoader.Save(config);
+ }
+ }
+
private void InitializeCoreServices()
{
_engine = new CopyPasteEngine(svc => new WindowsClipboardListener(svc));
diff --git a/CopyPaste.UI/Localization/Languages/en-US.json b/CopyPaste.UI/Localization/Languages/en-US.json
index 98858a0..2b14c2b 100644
--- a/CopyPaste.UI/Localization/Languages/en-US.json
+++ b/CopyPaste.UI/Localization/Languages/en-US.json
@@ -24,7 +24,11 @@
"selectColors": "Select colors...",
"selectTypes": "Select types..."
},
- "emptyState": "No items in this section"
+ "emptyState": "No items in this section",
+ "firstRun": {
+ "text": "Customize your experience in",
+ "action": "Settings"
+ }
},
"config": {
"window": {
diff --git a/CopyPaste.UI/Localization/Languages/es-CL.json b/CopyPaste.UI/Localization/Languages/es-CL.json
index f5e476d..bb88b6f 100644
--- a/CopyPaste.UI/Localization/Languages/es-CL.json
+++ b/CopyPaste.UI/Localization/Languages/es-CL.json
@@ -24,7 +24,11 @@
"selectColors": "Seleccionar colores...",
"selectTypes": "Seleccionar tipos..."
},
- "emptyState": "No hay elementos en esta sección"
+ "emptyState": "No hay elementos en esta sección",
+ "firstRun": {
+ "text": "Personaliza tu experiencia en",
+ "action": "Ajustes"
+ }
},
"config": {
"window": {
diff --git a/CopyPaste.UI/Themes/Compact/CompactWindow.xaml b/CopyPaste.UI/Themes/Compact/CompactWindow.xaml
index eddf50c..7b20ceb 100644
--- a/CopyPaste.UI/Themes/Compact/CompactWindow.xaml
+++ b/CopyPaste.UI/Themes/Compact/CompactWindow.xaml
@@ -15,6 +15,7 @@
+
@@ -91,7 +92,7 @@
-
+
@@ -272,8 +273,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+ MaxLines="1" VerticalAlignment="Center"
+ Visibility="{x:Bind DefaultHeaderVisibility, Mode=OneWay}"/>
@@ -443,17 +470,18 @@
-
-
+
+
-
+
+ MaxLines="1" TextTrimming="CharacterEllipsis" MaxWidth="140"
+ VerticalAlignment="Center"/>
-
+
"copypaste.default";
- public string Name => "Default";
+ public string Name => "Full";
public string Version => "1.0.0";
public string Author => "CopyPaste";
diff --git a/Tests/CopyPaste.Core.Tests/MyMConfigTests.cs b/Tests/CopyPaste.Core.Tests/MyMConfigTests.cs
index 8080524..aae61b9 100644
--- a/Tests/CopyPaste.Core.Tests/MyMConfigTests.cs
+++ b/Tests/CopyPaste.Core.Tests/MyMConfigTests.cs
@@ -23,10 +23,10 @@ public void DefaultConfig_RunOnStartup_IsTrue()
}
[Fact]
- public void DefaultConfig_ThemeId_IsDefault()
+ public void DefaultConfig_ThemeId_IsCompact()
{
var config = new MyMConfig();
- Assert.Equal("copypaste.default", config.ThemeId);
+ Assert.Equal("copypaste.compact", config.ThemeId);
}
[Fact]
diff --git a/Tests/CopyPaste.UI.Tests/ClipboardItemViewModelMediaTests.cs b/Tests/CopyPaste.UI.Tests/ClipboardItemViewModelMediaTests.cs
new file mode 100644
index 0000000..cc29207
--- /dev/null
+++ b/Tests/CopyPaste.UI.Tests/ClipboardItemViewModelMediaTests.cs
@@ -0,0 +1,571 @@
+using CopyPaste.Core;
+using CopyPaste.UI.Themes;
+using Microsoft.UI.Xaml;
+using System;
+using System.IO;
+using Xunit;
+
+namespace CopyPaste.UI.Tests;
+
+///
+/// Tests for ClipboardItemViewModel menu text properties and media-related members
+/// (ThumbnailPath, ImagePath, IsFileAvailable, etc.) that are not covered elsewhere.
+///
+public sealed class ClipboardItemViewModelMenuTextTests
+{
+ private static ClipboardItemViewModel Create(
+ ClipboardItem model,
+ Action? editAction = null) =>
+ new(model, _ => { }, (_, _) => { }, _ => { }, editAction);
+
+ [Fact]
+ public void PasteText_ReturnsNonEmptyString()
+ {
+ var vm = Create(new ClipboardItem { Content = "x", Type = ClipboardContentType.Text });
+
+ Assert.NotEmpty(vm.PasteText);
+ }
+
+ [Fact]
+ public void PastePlainText_ReturnsNonEmptyString()
+ {
+ var vm = Create(new ClipboardItem { Content = "x", Type = ClipboardContentType.Text });
+
+ Assert.NotEmpty(vm.PastePlainText);
+ }
+
+ [Fact]
+ public void DeleteText_ReturnsNonEmptyString()
+ {
+ var vm = Create(new ClipboardItem { Content = "x", Type = ClipboardContentType.Text });
+
+ Assert.NotEmpty(vm.DeleteText);
+ }
+
+ [Fact]
+ public void EditText_ReturnsNonEmptyString()
+ {
+ var vm = Create(new ClipboardItem { Content = "x", Type = ClipboardContentType.Text });
+
+ Assert.NotEmpty(vm.EditText);
+ }
+
+ [Fact]
+ public void FileWarningText_ReturnsNonEmptyString()
+ {
+ var vm = Create(new ClipboardItem { Content = "x", Type = ClipboardContentType.File });
+
+ Assert.NotEmpty(vm.FileWarningText);
+ }
+
+ [Fact]
+ public void HeaderTitle_ForTextType_ReturnsNonEmpty()
+ {
+ var vm = Create(new ClipboardItem { Content = "x", Type = ClipboardContentType.Text });
+
+ Assert.NotEmpty(vm.HeaderTitle);
+ }
+
+ [Fact]
+ public void PinMenuText_WhenUnpinned_ReturnsNonEmpty()
+ {
+ var model = new ClipboardItem { Content = "x", Type = ClipboardContentType.Text, IsPinned = false };
+ var vm = Create(model);
+
+ Assert.NotEmpty(vm.PinMenuText);
+ }
+
+ [Fact]
+ public void PinMenuText_WhenPinned_ReturnsNonEmpty()
+ {
+ var model = new ClipboardItem { Content = "x", Type = ClipboardContentType.Text, IsPinned = true };
+ var vm = Create(model);
+
+ Assert.NotEmpty(vm.PinMenuText);
+ }
+}
+
+public sealed class ClipboardItemViewModelFileAvailabilityTests
+{
+ [Fact]
+ public void IsFileAvailable_ForTextItem_AlwaysTrue()
+ {
+ var model = new ClipboardItem { Content = "hello", Type = ClipboardContentType.Text };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ Assert.True(vm.IsFileAvailable);
+ }
+
+ [Fact]
+ public void FileWarningVisibility_ForTextItem_IsCollapsed()
+ {
+ var model = new ClipboardItem { Content = "hello", Type = ClipboardContentType.Text };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ Assert.Equal(Visibility.Collapsed, vm.FileWarningVisibility);
+ }
+
+ [Fact]
+ public void IsFileAvailable_ForFileItemWithMissingPath_IsFalse()
+ {
+ var model = new ClipboardItem
+ {
+ Content = @"C:\this_path_does_not_exist_xyz\file.txt",
+ Type = ClipboardContentType.File
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ Assert.False(vm.IsFileAvailable);
+ }
+
+ [Fact]
+ public void FileWarningVisibility_ForMissingFile_IsVisible()
+ {
+ var model = new ClipboardItem
+ {
+ Content = @"C:\this_path_does_not_exist_xyz\file.txt",
+ Type = ClipboardContentType.File
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ Assert.Equal(Visibility.Visible, vm.FileWarningVisibility);
+ }
+}
+
+public sealed class ClipboardItemViewModelImageTests
+{
+ [Fact]
+ public void HasValidImagePath_ForTextItem_IsFalse()
+ {
+ var model = new ClipboardItem { Content = "hello", Type = ClipboardContentType.Text };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ Assert.False(vm.HasValidImagePath);
+ }
+
+ [Fact]
+ public void ImageVisibility_ForTextItem_IsCollapsed()
+ {
+ var model = new ClipboardItem { Content = "hello", Type = ClipboardContentType.Text };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ Assert.Equal(Visibility.Collapsed, vm.ImageVisibility);
+ }
+
+ [Fact]
+ public void HasValidImagePath_ForImageItemWithNoMetadata_IsTrue()
+ {
+ // For Image type, GetImagePathOrThumbnail returns the ms-appx placeholder,
+ // so HasValidImagePath = true even without a real file on disk.
+ var model = new ClipboardItem
+ {
+ Content = @"C:\nonexistent\image.png",
+ Type = ClipboardContentType.Image,
+ Metadata = null
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ Assert.True(vm.HasValidImagePath);
+ }
+
+ [Fact]
+ public void ImageVisibility_ForImageItem_IsVisible()
+ {
+ var model = new ClipboardItem
+ {
+ Content = @"C:\nonexistent\image.png",
+ Type = ClipboardContentType.Image,
+ Metadata = null
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ Assert.Equal(Visibility.Visible, vm.ImageVisibility);
+ }
+
+ [Fact]
+ public void ImagePath_ForImageItemWithNoMetadata_ReturnsPlaceholder()
+ {
+ var model = new ClipboardItem
+ {
+ Content = @"C:\nonexistent\file.png",
+ Type = ClipboardContentType.Image,
+ Metadata = null
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ var path = vm.ImagePath;
+
+ // Should return ms-appx placeholder for Image type when no valid path
+ Assert.NotEmpty(path);
+ }
+
+ [Fact]
+ public void ImagePath_SecondAccess_UsesCachedValue()
+ {
+ var model = new ClipboardItem
+ {
+ Content = @"C:\nonexistent\file.png",
+ Type = ClipboardContentType.Image,
+ Metadata = null
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ var first = vm.ImagePath;
+ var second = vm.ImagePath;
+
+ Assert.Equal(first, second);
+ }
+
+ [Fact]
+ public void ImagePath_ForImageItemWithThumbPathMetadata_ReturnsExpected()
+ {
+ var metadata = """{"thumb_path": "/nonexistent/thumb.jpg"}""";
+ var model = new ClipboardItem
+ {
+ Content = @"C:\nonexistent\image.png",
+ Type = ClipboardContentType.Image,
+ Metadata = metadata
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ var path = vm.ImagePath;
+
+ // thumb_path doesn't exist, so falls back to placeholder
+ Assert.NotEmpty(path);
+ }
+
+ [Fact]
+ public void ImagePath_ForImageItemWithExistingContentFile_ReturnsContentPath()
+ {
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ var model = new ClipboardItem
+ {
+ Content = tempFile,
+ Type = ClipboardContentType.Image,
+ Metadata = null
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ var path = vm.ImagePath;
+
+ Assert.Equal(tempFile, path);
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+}
+
+public sealed class ClipboardItemViewModelThumbnailTests
+{
+ [Fact]
+ public void ThumbnailPath_ForVideoItemWithNoMetadata_ReturnsVideoPlaceholder()
+ {
+ var model = new ClipboardItem
+ {
+ Content = @"C:\nonexistent\video.mp4",
+ Type = ClipboardContentType.Video,
+ Metadata = null
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ var path = vm.ThumbnailPath;
+
+ Assert.Contains("video", path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void ThumbnailPath_ForAudioItemWithNoMetadata_ReturnsAudioPlaceholder()
+ {
+ var model = new ClipboardItem
+ {
+ Content = @"C:\nonexistent\audio.mp3",
+ Type = ClipboardContentType.Audio,
+ Metadata = null
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ var path = vm.ThumbnailPath;
+
+ Assert.Contains("audio", path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void ThumbnailPath_SecondAccess_UsesCachedValue()
+ {
+ var model = new ClipboardItem
+ {
+ Content = @"C:\nonexistent\video.mp4",
+ Type = ClipboardContentType.Video,
+ Metadata = null
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ var first = vm.ThumbnailPath;
+ var second = vm.ThumbnailPath;
+
+ Assert.Equal(first, second);
+ }
+
+ [Fact]
+ public void ThumbnailPath_WithThumbPathMetadataButMissingFile_ReturnsPlaceholder()
+ {
+ var metadata = """{"thumb_path": "/nonexistent/path/thumb.jpg"}""";
+ var model = new ClipboardItem
+ {
+ Content = @"C:\nonexistent\video.mp4",
+ Type = ClipboardContentType.Video,
+ Metadata = metadata
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ var path = vm.ThumbnailPath;
+
+ // File doesn't exist, so falls back to placeholder
+ Assert.NotEmpty(path);
+ Assert.Contains("ms-appx", path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void ThumbnailPath_WithExistingThumbFile_ReturnsThumbnailPath()
+ {
+ var thumbFile = Path.GetTempFileName();
+ try
+ {
+ var metadata = $$"""{"thumb_path": "{{thumbFile.Replace("\\", "\\\\", StringComparison.Ordinal)}}"}""";
+ var model = new ClipboardItem
+ {
+ Content = @"C:\nonexistent\video.mp4",
+ Type = ClipboardContentType.Video,
+ Metadata = metadata
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ var path = vm.ThumbnailPath;
+
+ Assert.Equal(thumbFile, path);
+ }
+ finally
+ {
+ File.Delete(thumbFile);
+ }
+ }
+
+ [Fact]
+ public void ThumbnailPath_WithMetadataNoThumbPathProperty_ReturnsPlaceholder()
+ {
+ var metadata = """{"duration": 120, "file_size": 50000}""";
+ var model = new ClipboardItem
+ {
+ Content = @"C:\nonexistent\video.mp4",
+ Type = ClipboardContentType.Video,
+ Metadata = metadata
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ var path = vm.ThumbnailPath;
+
+ Assert.Contains("ms-appx", path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void ThumbnailPath_WithMalformedMetadata_ReturnsPlaceholder()
+ {
+ // Malformed JSON triggers JsonException catch in GetThumbnailPath, returns null,
+ // then GetThumbnailPathOrPlaceholder returns the placeholder.
+ var model = new ClipboardItem
+ {
+ Content = @"C:\nonexistent\video.mp4",
+ Type = ClipboardContentType.Video,
+ Metadata = "{ this is not valid json {{{"
+ };
+ var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { });
+
+ var path = vm.ThumbnailPath;
+
+ Assert.NotEmpty(path);
+ Assert.Contains("ms-appx", path, StringComparison.OrdinalIgnoreCase);
+ }
+}
+
+///
+/// Tests for exception-path coverage in GetThumbnailPath, GetImagePathOrThumbnail,
+/// GetImageDimensions, and GetMediaDuration (malformed JSON metadata).
+///
+public sealed class ClipboardItemViewModelMetadataExceptionTests
+{
+ private static ClipboardItemViewModel Create(ClipboardItem model) =>
+ new(model, _ => { }, (_, _) => { }, _ => { });
+
+ [Fact]
+ public void ImagePath_WithMalformedJson_DoesNotThrow()
+ {
+ // Malformed JSON triggers JsonException in GetThumbnailPath (caught),
+ // falls through to Image placeholder in GetImagePathOrThumbnail.
+ var model = new ClipboardItem
+ {
+ Content = @"C:\nonexistent\img.png",
+ Type = ClipboardContentType.Image,
+ Metadata = "not valid json at all {{{"
+ };
+ var vm = Create(model);
+
+ var exception = Record.Exception(() => _ = vm.ImagePath);
+
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void ImagePath_ForNonImageTypeWithNoValidContent_ReturnsEmpty()
+ {
+ // Link type with non-existent content → GetImagePathOrThumbnail returns string.Empty
+ var model = new ClipboardItem
+ {
+ Content = @"C:\this\does\not\exist.lnk",
+ Type = ClipboardContentType.Link,
+ Metadata = null
+ };
+ var vm = Create(model);
+
+ var path = vm.ImagePath;
+
+ Assert.Equal(string.Empty, path);
+ }
+
+ [Fact]
+ public void ImageDimensions_WithMalformedJson_ReturnsNull()
+ {
+ var model = new ClipboardItem
+ {
+ Content = "img.png",
+ Type = ClipboardContentType.Image,
+ Metadata = "not valid json {{{"
+ };
+ var vm = Create(model);
+
+ var dims = vm.ImageDimensions;
+
+ Assert.Null(dims);
+ }
+
+ [Fact]
+ public void MediaDuration_WithMalformedJson_ReturnsNull()
+ {
+ var model = new ClipboardItem
+ {
+ Content = "video.mp4",
+ Type = ClipboardContentType.Video,
+ Metadata = "{ badJson"
+ };
+ var vm = Create(model);
+
+ var duration = vm.MediaDuration;
+
+ Assert.Null(duration);
+ }
+
+ [Fact]
+ public void FileSize_WithMalformedJson_ReturnsNull()
+ {
+ var model = new ClipboardItem
+ {
+ Content = "file.txt",
+ Type = ClipboardContentType.File,
+ Metadata = "INVALID"
+ };
+ var vm = Create(model);
+
+ var size = vm.FileSize;
+
+ Assert.Null(size);
+ }
+}
+
+///
+/// Tests for missing branch coverage in GetImageDimensions, GetMediaDuration,
+/// GetImagePathOrThumbnail, and GetThumbnailPathOrPlaceholder.
+///
+public sealed class ClipboardItemViewModelMissingBranchTests
+{
+ private static ClipboardItemViewModel Create(ClipboardItem model) =>
+ new(model, _ => { }, (_, _) => { }, _ => { });
+
+ [Fact]
+ public void ImageDimensions_WithOnlyWidthProperty_ReturnsNull()
+ {
+ // TryGetProperty("height") fails → falls through the if → returns null
+ var model = new ClipboardItem
+ {
+ Content = "img.png",
+ Type = ClipboardContentType.Image,
+ Metadata = """{"width": 1920}"""
+ };
+ var vm = Create(model);
+
+ Assert.Null(vm.ImageDimensions);
+ }
+
+ [Fact]
+ public void ImageDimensions_WithOnlyHeightProperty_ReturnsNull()
+ {
+ // TryGetProperty("width") fails → returns null
+ var model = new ClipboardItem
+ {
+ Content = "img.png",
+ Type = ClipboardContentType.Image,
+ Metadata = """{"height": 1080}"""
+ };
+ var vm = Create(model);
+
+ Assert.Null(vm.ImageDimensions);
+ }
+
+ [Fact]
+ public void MediaDuration_WithMetadataButNoDurationKey_ReturnsNull()
+ {
+ // Metadata is valid JSON but has no "duration" property → TryGetProperty returns false → null
+ var model = new ClipboardItem
+ {
+ Content = "video.mp4",
+ Type = ClipboardContentType.Video,
+ Metadata = """{"file_size": 50000}"""
+ };
+ var vm = Create(model);
+
+ Assert.Null(vm.MediaDuration);
+ }
+
+ [Fact]
+ public void GetThumbnailPathOrPlaceholder_ForAudioType_ReturnsAudioPlaceholder()
+ {
+ // Covers the Audio branch in the switch expression
+ var model = new ClipboardItem
+ {
+ Content = "audio.mp3",
+ Type = ClipboardContentType.Audio,
+ Metadata = null
+ };
+ var vm = Create(model);
+
+ // ThumbnailPath goes through GetThumbnailPathOrPlaceholder
+ Assert.Contains("audio", vm.ThumbnailPath, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void GetImagePathOrThumbnail_ForNonImageNonExistentContent_ReturnsEmpty()
+ {
+ // Type != Image, Content path doesn't exist → returns string.Empty
+ var model = new ClipboardItem
+ {
+ Content = @"C:\does\not\exist\file.link",
+ Type = ClipboardContentType.Link,
+ Metadata = null
+ };
+ var vm = Create(model);
+
+ Assert.Equal(string.Empty, vm.ImagePath);
+ }
+}
diff --git a/Tests/CopyPaste.UI.Tests/LocalizationServiceTests.cs b/Tests/CopyPaste.UI.Tests/LocalizationServiceTests.cs
index 2282591..ca9cf83 100644
--- a/Tests/CopyPaste.UI.Tests/LocalizationServiceTests.cs
+++ b/Tests/CopyPaste.UI.Tests/LocalizationServiceTests.cs
@@ -170,6 +170,44 @@ public void Constructor_LoadsKeysSuccessfully()
Assert.NotEqual("[clipboard.contextMenu.delete]", clipboardKey);
}
+ [Fact]
+ public void Constructor_WithRegionalBaseLang_ResolvesToRegionalVariant()
+ {
+ // "es" has no exact match in available languages, but language-config.json
+ // maps regional "es" → "es-CL", so it should resolve to "es-CL".
+ _service = new LocalizationService("es");
+
+ Assert.Equal("es-CL", _service.CurrentLanguage);
+ }
+
+ [Fact]
+ public void Constructor_WithEsArg_FallsBackToEsCL()
+ {
+ // "es-AR" is not a supported language; base language "es" maps to "es-CL".
+ _service = new LocalizationService("es-AR");
+
+ Assert.Equal("es-CL", _service.CurrentLanguage);
+ }
+
+ [Fact]
+ public void Constructor_WithUnknownPreferredAndUnknownOsCulture_FallsBackToEnUs()
+ {
+ // Force OS culture to something not in available languages and without a regional mapping.
+ // This exercises GetGlobalFallback() → returns "en-US".
+ var original = System.Threading.Thread.CurrentThread.CurrentCulture;
+ try
+ {
+ System.Threading.Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("zh-CN");
+ // "zh-XX" not in availableLanguages, no regional.zh mapping → warn → OS "zh-CN" also not found, no regional.zh → GetGlobalFallback()
+ _service = new LocalizationService("zh-XX");
+ Assert.Equal("en-US", _service.CurrentLanguage);
+ }
+ finally
+ {
+ System.Threading.Thread.CurrentThread.CurrentCulture = original;
+ }
+ }
+
#endregion
public void Dispose()
diff --git a/Tests/CopyPaste.UI.Tests/SettingsLoadSaveTests.cs b/Tests/CopyPaste.UI.Tests/SettingsLoadSaveTests.cs
new file mode 100644
index 0000000..3d2efd7
--- /dev/null
+++ b/Tests/CopyPaste.UI.Tests/SettingsLoadSaveTests.cs
@@ -0,0 +1,268 @@
+using CopyPaste.Core;
+using CopyPaste.UI.Themes;
+using System;
+using System.IO;
+using System.Reflection;
+using System.Threading;
+using Xunit;
+
+namespace CopyPaste.UI.Tests;
+
+// Single semaphore ensures only one settings test holds the redirected _appDataPath at a time.
+internal static class SettingsTestSync
+{
+ internal static readonly SemaphoreSlim Gate = new(1, 1);
+ internal static readonly FieldInfo AppDataPathField =
+ typeof(StorageConfig).GetField("_appDataPath", BindingFlags.NonPublic | BindingFlags.Static)!;
+}
+
+[Collection("SettingsSerialTests")]
+public sealed class CompactSettingsLoadSaveTests : IDisposable
+{
+ private readonly string _tempDir;
+ private readonly string _originalPath;
+
+ public CompactSettingsLoadSaveTests()
+ {
+ SettingsTestSync.Gate.Wait();
+ _tempDir = Path.Combine(Path.GetTempPath(), "CopyPasteTests_Compact", Guid.NewGuid().ToString());
+ Directory.CreateDirectory(Path.Combine(_tempDir, "config"));
+ _originalPath = (SettingsTestSync.AppDataPathField.GetValue(null) as string)!;
+ SettingsTestSync.AppDataPathField.SetValue(null, _tempDir);
+ }
+
+ public void Dispose()
+ {
+ SettingsTestSync.AppDataPathField.SetValue(null, _originalPath);
+ try { Directory.Delete(_tempDir, recursive: true); } catch { }
+ SettingsTestSync.Gate.Release();
+ }
+
+ [Fact]
+ public void Load_WhenNoFileExists_ReturnsDefaultValues()
+ {
+ var settings = CompactSettings.Load();
+
+ Assert.NotNull(settings);
+ Assert.Equal(368, settings.PopupWidth);
+ }
+
+ [Fact]
+ public void Load_WhenValidFileExists_ReturnsSavedValues()
+ {
+ CompactSettings.Save(new CompactSettings { PopupWidth = 500, PopupHeight = 600 });
+
+ var loaded = CompactSettings.Load();
+
+ Assert.Equal(500, loaded.PopupWidth);
+ Assert.Equal(600, loaded.PopupHeight);
+ }
+
+ [Fact]
+ public void Load_WhenFileHasInvalidJson_ReturnsDefaultValues()
+ {
+ var configFile = Path.Combine(_tempDir, "config", "CompactTheme.json");
+ Directory.CreateDirectory(Path.GetDirectoryName(configFile)!);
+ File.WriteAllText(configFile, "{ this is: not valid json !!");
+
+ var settings = CompactSettings.Load();
+
+ Assert.NotNull(settings);
+ Assert.Equal(368, settings.PopupWidth);
+ }
+
+ [Fact]
+ public void Save_ReturnsTrueOnSuccess()
+ {
+ var result = CompactSettings.Save(new CompactSettings());
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void Save_PersistsBooleanProperties()
+ {
+ CompactSettings.Save(new CompactSettings
+ {
+ PinWindow = true,
+ ScrollToTopOnPaste = false,
+ HideOnDeactivate = false,
+ ResetSearchOnShow = false
+ });
+
+ var loaded = CompactSettings.Load();
+
+ Assert.True(loaded.PinWindow);
+ Assert.False(loaded.ScrollToTopOnPaste);
+ }
+
+ [Fact]
+ public void RoundTrip_PreservesAllWrittenValues()
+ {
+ CompactSettings.Save(new CompactSettings { PopupWidth = 380, PopupHeight = 520, CardMinLines = 1, CardMaxLines = 10 });
+
+ var loaded = CompactSettings.Load();
+
+ Assert.Equal(380, loaded.PopupWidth);
+ Assert.Equal(1, loaded.CardMinLines);
+ Assert.Equal(10, loaded.CardMaxLines);
+ }
+
+ [Fact]
+ public void Save_CreatesConfigDir_WhenMissing()
+ {
+ Directory.Delete(Path.Combine(_tempDir, "config"), recursive: true);
+
+ var result = CompactSettings.Save(new CompactSettings());
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void Load_WhenFileContainsJsonNull_ReturnsDefaults()
+ {
+ // JsonSerializer.Deserialize returns null for "null" JSON → Load falls back to defaults
+ var configFile = Path.Combine(_tempDir, "config", "CompactTheme.json");
+ File.WriteAllText(configFile, "null");
+
+ var settings = CompactSettings.Load();
+
+ Assert.NotNull(settings);
+ Assert.Equal(368, settings.PopupWidth);
+ }
+
+ [Fact]
+ public void Save_ReturnsFalse_WhenFileLocked()
+ {
+ // Create a locked/read-only file → WriteAllText throws; Save returns false
+ var configFile = Path.Combine(_tempDir, "config", "CompactTheme.json");
+ File.WriteAllText(configFile, "{}");
+ // Hold an exclusive read lock on the file
+ using var lockHandle = new FileStream(configFile, FileMode.Open, FileAccess.Read, FileShare.None);
+
+ var result = CompactSettings.Save(new CompactSettings());
+
+ Assert.False(result);
+ }
+}
+
+[Collection("SettingsSerialTests")]
+public sealed class DefaultThemeSettingsLoadSaveTests : IDisposable
+{
+ private readonly string _tempDir;
+ private readonly string _originalPath;
+
+ public DefaultThemeSettingsLoadSaveTests()
+ {
+ SettingsTestSync.Gate.Wait();
+ _tempDir = Path.Combine(Path.GetTempPath(), "CopyPasteTests_Default", Guid.NewGuid().ToString());
+ Directory.CreateDirectory(Path.Combine(_tempDir, "config"));
+ _originalPath = (SettingsTestSync.AppDataPathField.GetValue(null) as string)!;
+ SettingsTestSync.AppDataPathField.SetValue(null, _tempDir);
+ }
+
+ public void Dispose()
+ {
+ SettingsTestSync.AppDataPathField.SetValue(null, _originalPath);
+ try { Directory.Delete(_tempDir, recursive: true); } catch { }
+ SettingsTestSync.Gate.Release();
+ }
+
+ [Fact]
+ public void Load_WhenNoFileExists_ReturnsDefaultValues()
+ {
+ var settings = DefaultThemeSettings.Load();
+
+ Assert.NotNull(settings);
+ Assert.Equal(400, settings.WindowWidth);
+ }
+
+ [Fact]
+ public void Load_WhenValidFileExists_ReturnsSavedValues()
+ {
+ DefaultThemeSettings.Save(new DefaultThemeSettings { WindowWidth = 600, CardMaxLines = 12 });
+
+ var loaded = DefaultThemeSettings.Load();
+
+ Assert.Equal(600, loaded.WindowWidth);
+ Assert.Equal(12, loaded.CardMaxLines);
+ }
+
+ [Fact]
+ public void Load_WhenFileHasInvalidJson_ReturnsDefaultValues()
+ {
+ var configFile = Path.Combine(_tempDir, "config", "DefaultTheme.json");
+ Directory.CreateDirectory(Path.GetDirectoryName(configFile)!);
+ File.WriteAllText(configFile, "INVALID {{{ JSON");
+
+ var settings = DefaultThemeSettings.Load();
+
+ Assert.NotNull(settings);
+ Assert.Equal(400, settings.WindowWidth);
+ }
+
+ [Fact]
+ public void Save_ReturnsTrueOnSuccess()
+ {
+ var result = DefaultThemeSettings.Save(new DefaultThemeSettings());
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void Save_PersistsMarginValues()
+ {
+ DefaultThemeSettings.Save(new DefaultThemeSettings { WindowMarginTop = 4, WindowMarginBottom = 20 });
+
+ var loaded = DefaultThemeSettings.Load();
+
+ Assert.Equal(4, loaded.WindowMarginTop);
+ Assert.Equal(20, loaded.WindowMarginBottom);
+ }
+
+ [Fact]
+ public void RoundTrip_PreservesAllWrittenValues()
+ {
+ DefaultThemeSettings.Save(new DefaultThemeSettings { WindowWidth = 500, CardMinLines = 2, CardMaxLines = 10, PinWindow = true });
+
+ var loaded = DefaultThemeSettings.Load();
+
+ Assert.Equal(500, loaded.WindowWidth);
+ Assert.Equal(2, loaded.CardMinLines);
+ Assert.True(loaded.PinWindow);
+ }
+
+ [Fact]
+ public void Save_CreatesConfigDir_WhenMissing()
+ {
+ Directory.Delete(Path.Combine(_tempDir, "config"), recursive: true);
+
+ var result = DefaultThemeSettings.Save(new DefaultThemeSettings());
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void Load_WhenFileContainsJsonNull_ReturnsDefaults()
+ {
+ var configFile = Path.Combine(_tempDir, "config", "DefaultTheme.json");
+ File.WriteAllText(configFile, "null");
+
+ var settings = DefaultThemeSettings.Load();
+
+ Assert.NotNull(settings);
+ Assert.Equal(400, settings.WindowWidth);
+ }
+
+ [Fact]
+ public void Save_ReturnsFalse_WhenFileLocked()
+ {
+ var configFile = Path.Combine(_tempDir, "config", "DefaultTheme.json");
+ File.WriteAllText(configFile, "{}");
+ using var lockHandle = new FileStream(configFile, FileMode.Open, FileAccess.Read, FileShare.None);
+
+ var result = DefaultThemeSettings.Save(new DefaultThemeSettings());
+
+ Assert.False(result);
+ }
+}
diff --git a/Tests/CopyPaste.UI.Tests/ViewModelMiscCommandTests.cs b/Tests/CopyPaste.UI.Tests/ViewModelMiscCommandTests.cs
new file mode 100644
index 0000000..9b5b739
--- /dev/null
+++ b/Tests/CopyPaste.UI.Tests/ViewModelMiscCommandTests.cs
@@ -0,0 +1,360 @@
+using CopyPaste.Core;
+using CopyPaste.UI.Themes;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using Xunit;
+
+namespace CopyPaste.UI.Tests;
+
+public sealed class ViewModelClearSearchCommandTests
+{
+ private static DefaultThemeViewModel CreateViewModel(IClipboardService? service = null)
+ {
+ service ??= new ClearSearchEmptyStub();
+ return new DefaultThemeViewModel(service, new MyMConfig(), new DefaultThemeSettings());
+ }
+
+ [Fact]
+ public void ClearSearchCommand_ClearsNonEmptySearchQuery()
+ {
+ var vm = CreateViewModel();
+ vm.ToggleColorFilter(CardColor.Red);
+ vm.SearchQuery = "clipboard";
+
+ vm.ClearSearchCommand.Execute(null);
+
+ Assert.Equal(string.Empty, vm.SearchQuery);
+ }
+
+ [Fact]
+ public void ClearSearchCommand_WhenAlreadyEmpty_DoesNotThrow()
+ {
+ var vm = CreateViewModel();
+ vm.SearchQuery = string.Empty;
+
+ var exception = Record.Exception(() => vm.ClearSearchCommand.Execute(null));
+
+ Assert.Null(exception);
+ Assert.Equal(string.Empty, vm.SearchQuery);
+ }
+
+ [Fact]
+ public void ClearSearchCommand_FiresPropertyChangedForSearchQuery()
+ {
+ var vm = CreateViewModel();
+ vm.SearchQuery = "find me";
+ var changedProps = new List();
+ vm.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName);
+
+ vm.ClearSearchCommand.Execute(null);
+
+ Assert.Contains(nameof(vm.SearchQuery), changedProps);
+ }
+
+ [Fact]
+ public void ClearSearchCommand_WithWhitespaceQuery_ClearsToEmpty()
+ {
+ var vm = CreateViewModel();
+ vm.SearchQuery = " ";
+
+ vm.ClearSearchCommand.Execute(null);
+
+ Assert.Equal(string.Empty, vm.SearchQuery);
+ }
+}
+
+public sealed class ViewModelRefreshFileAvailabilityTests
+{
+ private static DefaultThemeViewModel CreateWithItems(List items)
+ {
+ var service = new FileAvailabilityItemsStub(items);
+ var config = new MyMConfig { PageSize = 50, MaxItemsBeforeCleanup = 100 };
+ var vm = new DefaultThemeViewModel(service, config, new DefaultThemeSettings());
+ vm.ToggleColorFilter(CardColor.Red); // triggers initial load
+ return vm;
+ }
+
+ [Fact]
+ public void RefreshFileAvailability_WithEmptyItems_DoesNotThrow()
+ {
+ var vm = CreateWithItems([]);
+
+ var exception = Record.Exception(() => vm.RefreshFileAvailability());
+
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void RefreshFileAvailability_WithOnlyTextItems_DoesNotThrow()
+ {
+ var items = new List
+ {
+ new() { Content = "hello", Type = ClipboardContentType.Text },
+ new() { Content = "world", Type = ClipboardContentType.Text },
+ };
+ var vm = CreateWithItems(items);
+
+ var exception = Record.Exception(() => vm.RefreshFileAvailability());
+
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void RefreshFileAvailability_OnFileTypeItem_FiresFileWarningVisibilityPropertyChanged()
+ {
+ var fileItem = new ClipboardItem
+ {
+ Content = @"C:\this\does\not\exist\file.txt",
+ Type = ClipboardContentType.File
+ };
+ var vm = CreateWithItems([fileItem]);
+ var fileItemVM = vm.Items.First(i => i.IsFileType);
+ var changedProps = new List();
+ fileItemVM.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName);
+
+ vm.RefreshFileAvailability();
+
+ Assert.Contains(nameof(ClipboardItemViewModel.FileWarningVisibility), changedProps);
+ }
+
+ [Fact]
+ public void RefreshFileAvailability_OnFolderTypeItem_FiresPropertyChanged()
+ {
+ var folderItem = new ClipboardItem
+ {
+ Content = @"C:\this\does\not\exist\",
+ Type = ClipboardContentType.Folder
+ };
+ var vm = CreateWithItems([folderItem]);
+ var folderItemVM = vm.Items.First(i => i.IsFileType);
+ var changedProps = new List();
+ folderItemVM.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName);
+
+ vm.RefreshFileAvailability();
+
+ Assert.NotEmpty(changedProps);
+ }
+
+ [Fact]
+ public void RefreshFileAvailability_TextItemsAreSkipped_NoPropertyChangedFired()
+ {
+ var textItem = new ClipboardItem { Content = "plain text", Type = ClipboardContentType.Text };
+ var vm = CreateWithItems([textItem]);
+ var textItemVM = vm.Items.First();
+ var changedProps = new List();
+ textItemVM.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName);
+
+ vm.RefreshFileAvailability();
+
+ Assert.Empty(changedProps);
+ }
+
+ [Fact]
+ public void RefreshFileAvailability_MixedItems_OnlyFileTypesRefreshed()
+ {
+ var fileItem = new ClipboardItem
+ {
+ Content = @"C:\nonexistent.txt",
+ Type = ClipboardContentType.File
+ };
+ var textItem = new ClipboardItem { Content = "text", Type = ClipboardContentType.Text };
+ var vm = CreateWithItems([fileItem, textItem]);
+
+ var fileItemVM = vm.Items.First(i => i.IsFileType);
+ var textItemVM = vm.Items.First(i => !i.IsFileType);
+
+ var fileChangedProps = new List();
+ var textChangedProps = new List();
+ fileItemVM.PropertyChanged += (_, e) => fileChangedProps.Add(e.PropertyName);
+ textItemVM.PropertyChanged += (_, e) => textChangedProps.Add(e.PropertyName);
+
+ vm.RefreshFileAvailability();
+
+ Assert.NotEmpty(fileChangedProps);
+ Assert.Empty(textChangedProps);
+ }
+}
+
+public sealed class ViewModelCallbackTests
+{
+ private static (DefaultThemeViewModel vm, ViewModelCallbackStubService service) CreateVmWithItems(
+ List items)
+ {
+ var service = new ViewModelCallbackStubService(items);
+ var config = new MyMConfig { PageSize = 50, MaxItemsBeforeCleanup = 100 };
+ var vm = new DefaultThemeViewModel(service, config, new DefaultThemeSettings());
+ vm.ToggleColorFilter(CardColor.Red); // trigger load
+ return (vm, service);
+ }
+
+ [Fact]
+ public void IsWindowPinned_DefaultThemeViewModel_ReturnsFalse()
+ {
+ var service = new ViewModelCallbackStubService([]);
+ var vm = new DefaultThemeViewModel(service, new MyMConfig(), new DefaultThemeSettings());
+
+ Assert.False(vm.IsWindowPinned);
+ }
+
+ [Fact]
+ public void DeleteCommand_OnLoadedItem_RemovesItemFromList()
+ {
+ var items = new List
+ {
+ new() { Content = "item1", Type = ClipboardContentType.Text },
+ new() { Content = "item2", Type = ClipboardContentType.Text }
+ };
+ var (vm, _) = CreateVmWithItems(items);
+ var toDelete = vm.Items[0];
+
+ toDelete.DeleteCommand.Execute(null);
+
+ Assert.Single(vm.Items);
+ }
+
+ [Fact]
+ public void DeleteCommand_OnLoadedItem_CallsServiceRemove()
+ {
+ var item = new ClipboardItem { Content = "test", Type = ClipboardContentType.Text };
+ var (vm, service) = CreateVmWithItems([item]);
+ var toDelete = vm.Items[0];
+
+ toDelete.DeleteCommand.Execute(null);
+
+ Assert.Contains(item.Id, service.RemovedIds);
+ }
+
+ [Fact]
+ public void DeleteCommand_LastItem_SetsIsEmptyTrue()
+ {
+ var item = new ClipboardItem { Content = "only", Type = ClipboardContentType.Text };
+ var (vm, _) = CreateVmWithItems([item]);
+ var toDelete = vm.Items[0];
+
+ toDelete.DeleteCommand.Execute(null);
+
+ Assert.True(vm.IsEmpty);
+ }
+
+ [Fact]
+ public void TogglePinCommand_OnLoadedItem_CallsServiceUpdatePin()
+ {
+ var item = new ClipboardItem { Content = "pinme", Type = ClipboardContentType.Text, IsPinned = false };
+ var (vm, service) = CreateVmWithItems([item]);
+ var toPin = vm.Items[0];
+
+ toPin.TogglePinCommand.Execute(null);
+
+ Assert.Contains(item.Id, service.PinnedIds);
+ }
+
+ [Fact]
+ public void EditCommand_OnLoadedItem_FiresOnEditRequestedEvent()
+ {
+ var item = new ClipboardItem { Content = "edit me", Type = ClipboardContentType.Text };
+ var (vm, _) = CreateVmWithItems([item]);
+ var toEdit = vm.Items[0];
+ ClipboardItemViewModel? receivedVM = null;
+ vm.OnEditRequested += (_, e) => receivedVM = e;
+
+ toEdit.EditCommand.Execute(null);
+
+ Assert.NotNull(receivedVM);
+ Assert.Equal(item.Id, receivedVM.Model.Id);
+ }
+
+ [Fact]
+ public void SelectedTabIndex_SetTo2_LoadsAllItemsWithoutFilter()
+ {
+ var items = new List
+ {
+ new() { Content = "pinned", Type = ClipboardContentType.Text, IsPinned = true },
+ new() { Content = "unpinned", Type = ClipboardContentType.Text, IsPinned = false }
+ };
+ var (vm, _) = CreateVmWithItems(items);
+
+ // SelectedTabIndex=2 triggers CurrentPinnedFilter = null (all items)
+ var exception = Record.Exception(() => vm.SelectedTabIndex = 2);
+
+ Assert.Null(exception);
+ }
+}
+
+internal sealed class ViewModelCallbackStubService : IClipboardService
+{
+ private readonly List _items;
+ public List RemovedIds { get; } = [];
+ public List PinnedIds { get; } = [];
+
+ public ViewModelCallbackStubService(List items) => _items = items;
+
+#pragma warning disable CS0067
+ public event Action? OnItemAdded;
+ public event Action? OnThumbnailReady;
+ public event Action? OnItemReactivated;
+#pragma warning restore CS0067
+ public int PasteIgnoreWindowMs { get; set; } = 450;
+
+ public IEnumerable GetHistoryAdvanced(int limit, int skip, string? query,
+ IReadOnlyCollection? types, IReadOnlyCollection? colors, bool? isPinned)
+ => _items.Skip(skip).Take(limit);
+
+ public void RemoveItem(Guid id) => RemovedIds.Add(id);
+ public void UpdatePin(Guid id, bool isPinned) => PinnedIds.Add(id);
+ public void AddText(string? text, ClipboardContentType type, string? source, byte[]? rtfBytes = null, byte[]? htmlBytes = null) { }
+ public void AddImage(byte[]? dibData, string? source) { }
+ public void AddFiles(System.Collections.ObjectModel.Collection? files, ClipboardContentType type, string? source) { }
+ public IEnumerable GetHistory(int limit = 50, int skip = 0, string? query = null, bool? isPinned = null) => [];
+ public void UpdateLabelAndColor(Guid id, string? label, CardColor color) { }
+ public ClipboardItem? MarkItemUsed(Guid id) => null;
+ public void NotifyPasteInitiated(Guid itemId) { }
+}
+
+internal sealed class ClearSearchEmptyStub : IClipboardService
+{
+#pragma warning disable CS0067
+ public event Action? OnItemAdded;
+ public event Action? OnThumbnailReady;
+ public event Action? OnItemReactivated;
+#pragma warning restore CS0067
+ public int PasteIgnoreWindowMs { get; set; } = 450;
+ public void AddText(string? text, ClipboardContentType type, string? source, byte[]? rtfBytes = null, byte[]? htmlBytes = null) { }
+ public void AddImage(byte[]? dibData, string? source) { }
+ public void AddFiles(Collection? files, ClipboardContentType type, string? source) { }
+ public IEnumerable GetHistory(int limit = 50, int skip = 0, string? query = null, bool? isPinned = null) => [];
+ public IEnumerable GetHistoryAdvanced(int limit, int skip, string? query, IReadOnlyCollection? types, IReadOnlyCollection? colors, bool? isPinned) => [];
+ public void RemoveItem(Guid id) { }
+ public void UpdatePin(Guid id, bool isPinned) { }
+ public void UpdateLabelAndColor(Guid id, string? label, CardColor color) { }
+ public ClipboardItem? MarkItemUsed(Guid id) => null;
+ public void NotifyPasteInitiated(Guid itemId) { }
+}
+
+internal sealed class FileAvailabilityItemsStub : IClipboardService
+{
+ private readonly List _items;
+
+ public FileAvailabilityItemsStub(List items) => _items = items;
+
+#pragma warning disable CS0067
+ public event Action? OnItemAdded;
+ public event Action? OnThumbnailReady;
+ public event Action? OnItemReactivated;
+#pragma warning restore CS0067
+ public int PasteIgnoreWindowMs { get; set; } = 450;
+
+ public IEnumerable GetHistoryAdvanced(int limit, int skip, string? query, IReadOnlyCollection? types, IReadOnlyCollection? colors, bool? isPinned)
+ => _items.Skip(skip).Take(limit);
+
+ public void AddText(string? text, ClipboardContentType type, string? source, byte[]? rtfBytes = null, byte[]? htmlBytes = null) { }
+ public void AddImage(byte[]? dibData, string? source) { }
+ public void AddFiles(Collection? files, ClipboardContentType type, string? source) { }
+ public IEnumerable GetHistory(int limit = 50, int skip = 0, string? query = null, bool? isPinned = null) => [];
+ public void RemoveItem(Guid id) { }
+ public void UpdatePin(Guid id, bool isPinned) { }
+ public void UpdateLabelAndColor(Guid id, string? label, CardColor color) { }
+ public ClipboardItem? MarkItemUsed(Guid id) => null;
+ public void NotifyPasteInitiated(Guid itemId) { }
+}