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
34 changes: 22 additions & 12 deletions src/PersonalResourceWorkspace.App/MainPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
}
}

Expand Down Expand Up @@ -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 = "Закрыть",
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace PersonalResourceWorkspace.Application.Abstractions;

public interface IClipboardTextWriter
{
Task WriteTextAsync(string text, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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}");
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public static IServiceCollection AddWindowsPlatform(this IServiceCollection serv
services.AddSingleton<IAppDataPathProvider, UnpackagedAppDataPathProvider>();
services.AddSingleton<ICollectionViewPreferenceStore, FileCollectionViewPreferenceStore>();
services.AddSingleton<IClipboardService, WindowsClipboardService>();
services.AddSingleton<IClipboardTextWriter, WindowsClipboardTextWriter>();
services.AddSingleton<IShellLauncher, WindowsShellLauncher>();
services.AddSingleton<IResourceVisualService, WindowsResourceVisualService>();
return services;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);

Expand All @@ -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<InvalidOperationException>(
() => service.LaunchAsync(StoredResourceKinds.Web, "javascript:alert(1)", CancellationToken.None));
Expand All @@ -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);

Expand All @@ -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<OperationCanceledException>(() =>
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<InvalidOperationException>(() =>
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)]
Expand Down Expand Up @@ -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;
}
}
}
Loading