diff --git a/src/PersonalResourceWorkspace.App/MainPage.xaml.cs b/src/PersonalResourceWorkspace.App/MainPage.xaml.cs index 55a8268..5493754 100644 --- a/src/PersonalResourceWorkspace.App/MainPage.xaml.cs +++ b/src/PersonalResourceWorkspace.App/MainPage.xaml.cs @@ -166,18 +166,24 @@ private void SearchBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChan private async void AddResource_Click(object sender, RoutedEventArgs e) { - var collection = GetDefaultCollection(); - var draft = await TryReadClipboardDraftAsync(); - var resource = draft is null - ? CreateEmptyResource(collection) - : ToWorkspaceResource(draft, collection); + try + { + var collection = GetDefaultCollection(); + var draft = await TryReadClipboardDraftAsync(); + var resource = draft is null + ? CreateEmptyResource(collection) + : ToWorkspaceResource(draft, collection); - if (await ShowResourceEditorAsync(resource, isNew: true)) + if (await ShowResourceEditorAsync(resource, isNew: true)) + { + _resources.Insert(0, resource); + ApplyFilter(); + StatusText.Text = $"Добавлено: {resource.Title}"; + } + } + catch (Exception exception) { - _resources.Insert(0, resource); - ApplyFilter(); - await SaveResourcesAsync(); - StatusText.Text = $"Добавлено: {resource.Title}"; + StatusText.Text = $"Не удалось добавить ресурс: {exception.Message}"; } } @@ -1115,14 +1121,18 @@ private async Task OpenResourceAsync(string kind, string address) try { await _resourceLaunchService.LaunchAsync(kind, address, CancellationToken.None); - StatusText.Text = $"Открывается: {address.Trim()}"; + StatusText.Text = ResourceTypeMapper.FromStoredKind(kind) == ResourceType.TextSnippet + ? "Текст заметки скопирован в буфер обмена" + : $"Открывается: {address.Trim()}"; } catch (Exception exception) { var errorDialog = new ContentDialog { XamlRoot = XamlRoot, - Title = "Не удалось открыть ресурс", + Title = ResourceTypeMapper.FromStoredKind(kind) == ResourceType.TextSnippet + ? "Не удалось скопировать заметку" + : "Не удалось открыть ресурс", Content = exception.Message, CloseButtonText = "Закрыть", }; diff --git a/src/PersonalResourceWorkspace.Application/Abstractions/IClipboardTextWriter.cs b/src/PersonalResourceWorkspace.Application/Abstractions/IClipboardTextWriter.cs new file mode 100644 index 0000000..8458ba9 --- /dev/null +++ b/src/PersonalResourceWorkspace.Application/Abstractions/IClipboardTextWriter.cs @@ -0,0 +1,6 @@ +namespace PersonalResourceWorkspace.Application.Abstractions; + +public interface IClipboardTextWriter +{ + Task WriteTextAsync(string text, CancellationToken cancellationToken); +} diff --git a/src/PersonalResourceWorkspace.Application/Resources/ResourceLaunchService.cs b/src/PersonalResourceWorkspace.Application/Resources/ResourceLaunchService.cs index c5378dd..7b4f82f 100644 --- a/src/PersonalResourceWorkspace.Application/Resources/ResourceLaunchService.cs +++ b/src/PersonalResourceWorkspace.Application/Resources/ResourceLaunchService.cs @@ -3,7 +3,7 @@ namespace PersonalResourceWorkspace.Application.Resources; -public sealed class ResourceLaunchService(IShellLauncher shellLauncher) +public sealed class ResourceLaunchService(IShellLauncher shellLauncher, IClipboardTextWriter clipboardTextWriter) { public async Task LaunchAsync(string kind, string address, CancellationToken cancellationToken) { @@ -16,12 +16,15 @@ public async Task LaunchAsync(string kind, string address, CancellationToken can case ResourceType.Application: case ResourceType.File: case ResourceType.Folder: - case ResourceType.TextSnippet: case ResourceType.ImageSnippet: case ResourceType.Command: case ResourceType.Collection: await LaunchPathAsync(address, cancellationToken); break; + case ResourceType.TextSnippet: + cancellationToken.ThrowIfCancellationRequested(); + await clipboardTextWriter.WriteTextAsync(address, cancellationToken); + break; default: ResourceType unreachable = type; throw new InvalidOperationException($"Unknown resource type: {unreachable}"); diff --git a/src/PersonalResourceWorkspace.Windows/Clipboard/WindowsClipboardTextWriter.cs b/src/PersonalResourceWorkspace.Windows/Clipboard/WindowsClipboardTextWriter.cs new file mode 100644 index 0000000..51a8fd0 --- /dev/null +++ b/src/PersonalResourceWorkspace.Windows/Clipboard/WindowsClipboardTextWriter.cs @@ -0,0 +1,18 @@ +using PersonalResourceWorkspace.Application.Abstractions; +using Windows.ApplicationModel.DataTransfer; +using WinClipboard = Windows.ApplicationModel.DataTransfer.Clipboard; + +namespace PersonalResourceWorkspace.Windows.Clipboard; + +internal sealed class WindowsClipboardTextWriter : IClipboardTextWriter +{ + public Task WriteTextAsync(string text, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var content = new DataPackage(); + content.SetText(text); + cancellationToken.ThrowIfCancellationRequested(); + WinClipboard.SetContent(content); + return Task.CompletedTask; + } +} diff --git a/src/PersonalResourceWorkspace.Windows/DependencyInjection.cs b/src/PersonalResourceWorkspace.Windows/DependencyInjection.cs index 60daecf..a6e8159 100644 --- a/src/PersonalResourceWorkspace.Windows/DependencyInjection.cs +++ b/src/PersonalResourceWorkspace.Windows/DependencyInjection.cs @@ -14,6 +14,7 @@ public static IServiceCollection AddWindowsPlatform(this IServiceCollection serv services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); return services; diff --git a/tests/PersonalResourceWorkspace.Application.Tests/Resources/ResourceLaunchServiceTests.cs b/tests/PersonalResourceWorkspace.Application.Tests/Resources/ResourceLaunchServiceTests.cs index a14310e..fb1d92c 100644 --- a/tests/PersonalResourceWorkspace.Application.Tests/Resources/ResourceLaunchServiceTests.cs +++ b/tests/PersonalResourceWorkspace.Application.Tests/Resources/ResourceLaunchServiceTests.cs @@ -10,7 +10,7 @@ public sealed class ResourceLaunchServiceTests public async Task LaunchAsyncOpensHttpUriInBrowser() { var launcher = new FakeShellLauncher(); - var service = new ResourceLaunchService(launcher); + var service = new ResourceLaunchService(launcher, new FakeClipboardTextWriter()); await service.LaunchAsync(StoredResourceKinds.Web, "https://example.com/docs", CancellationToken.None); @@ -23,7 +23,7 @@ public async Task LaunchAsyncOpensHttpUriInBrowser() public async Task LaunchAsyncNormalizesSchemeLessWebAddress() { var launcher = new FakeShellLauncher(); - var service = new ResourceLaunchService(launcher); + var service = new ResourceLaunchService(launcher, new FakeClipboardTextWriter()); await service.LaunchAsync(StoredResourceKinds.LegacyWeb, "example.com", CancellationToken.None); @@ -34,7 +34,7 @@ public async Task LaunchAsyncNormalizesSchemeLessWebAddress() public async Task LaunchAsyncRejectsNonHttpWebAddress() { var launcher = new FakeShellLauncher(); - var service = new ResourceLaunchService(launcher); + var service = new ResourceLaunchService(launcher, new FakeClipboardTextWriter()); var exception = await Assert.ThrowsAsync( () => service.LaunchAsync(StoredResourceKinds.Web, "javascript:alert(1)", CancellationToken.None)); @@ -47,7 +47,7 @@ public async Task LaunchAsyncRejectsNonHttpWebAddress() public async Task LaunchAsyncOpensFilePath() { var launcher = new FakeShellLauncher(); - var service = new ResourceLaunchService(launcher); + var service = new ResourceLaunchService(launcher, new FakeClipboardTextWriter()); await service.LaunchAsync(StoredResourceKinds.File, @"C:\docs\file.txt", CancellationToken.None); @@ -63,6 +63,51 @@ public void MapperNormalizesLegacyWebKind() Assert.Equal("\uE71B", ResourceTypeMapper.GlyphForStoredKind(StoredResourceKinds.Web)); } + [Fact] + public async Task LaunchAsyncCopiesNoteExactlyOnceWithoutOpeningShell() + { + var launcher = new FakeShellLauncher(); + var clipboard = new FakeClipboardTextWriter(); + var service = new ResourceLaunchService(launcher, clipboard); + const string content = " первая строка\r\nвторая строка "; + + await service.LaunchAsync(StoredResourceKinds.Note, content, CancellationToken.None); + + Assert.Equal(content, clipboard.LastText); + Assert.Equal(1, clipboard.WriteCount); + Assert.Null(launcher.LastPath); + Assert.Null(launcher.LastUri); + } + + [Fact] + public async Task LaunchAsyncDoesNotCopyCancelledNote() + { + var clipboard = new FakeClipboardTextWriter(); + var service = new ResourceLaunchService(new FakeShellLauncher(), clipboard); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => + service.LaunchAsync(StoredResourceKinds.Note, "text", cancellation.Token)); + + Assert.Equal(0, clipboard.WriteCount); + } + + [Fact] + public async Task LaunchAsyncPropagatesClipboardErrorWithoutShellFallback() + { + var launcher = new FakeShellLauncher(); + var clipboard = new FakeClipboardTextWriter { Error = new InvalidOperationException("clipboard unavailable") }; + var service = new ResourceLaunchService(launcher, clipboard); + + await Assert.ThrowsAsync(() => + service.LaunchAsync(StoredResourceKinds.Note, "text", CancellationToken.None)); + + Assert.Equal(1, clipboard.WriteCount); + Assert.Null(launcher.LastPath); + Assert.Null(launcher.LastUri); + } + [Theory] [InlineData(StoredResourceKinds.Application, ResourceType.Application)] [InlineData(StoredResourceKinds.Web, ResourceType.Web)] @@ -96,4 +141,19 @@ public Task OpenPathAsync(string path, CancellationToken cancellationToken) return Task.CompletedTask; } } + + private sealed class FakeClipboardTextWriter : IClipboardTextWriter + { + public string? LastText { get; private set; } + public int WriteCount { get; private set; } + public Exception? Error { get; init; } + + public Task WriteTextAsync(string text, CancellationToken cancellationToken) + { + WriteCount++; + if (Error is not null) throw Error; + LastText = text; + return Task.CompletedTask; + } + } }