diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c991d26..a06aadd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: ci on: push: - branches: [master] + branches: ['**'] pull_request: branches: [master] diff --git a/CHANGELOG.md b/CHANGELOG.md index ce2e3d1..b0ae832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added — Phase 4: Authoring + parameter-fill flyout +- Snip card actions are now live: **Copy** opens a parameter-fill + `ContentDialog` (or copies the template directly when the Snip has no + parameters), **Edit** opens a Snip editor, **Delete** soft-trashes after + confirmation, the **star** toggles favourite. +- Copying a Snip bumps `UsageCount` and `LastUsedAt` (drives the most-used + list on Home). +- New Snip / New CLI buttons on Home and the CLI view. Edit CLI button on the + CLI view header. +- `SnipEditorDialog` — title, command template (monospace, multi-line), + description, tags (comma-separated), parameter rows with type + (Text / Choice), default, and options. Add/remove parameters inline. +- `CliEditorDialog` — name + icon picker. Picked images are normalised to a + 256² centre-square PNG via `WindowsIconNormaliser` (`Windows.Graphics.Imaging`) + and stored under `/icons/.png` by `IconAssetStorage`. +- `ParameterFillDialog` — one input per parameter (TextBox for `Text`, + ComboBox for `Choice`), with a live preview of the resolved command and + the Copy button disabled until the template is fully resolved. +- New Core abstractions: `IClipboardService`, `IIconNormaliser`, + `IIconAssetStorage`, `IShellInteractions`. +- New Core view models: `ParameterFillViewModel`, `ParameterInputViewModel`, + `SnipEditorViewModel`, `ParameterEditorRowViewModel`, `CliEditorViewModel`. +- `ShellViewModel` gains `CopySnipCommand`, `EditSnipCommand`, + `DeleteSnipCommand`, `ToggleFavouriteCommand`, `NewSnipCommand`, + `NewCliCommand`, `EditCurrentCliCommand`, `SelectCliCommand`. +- App-side implementations: `WindowsClipboardService`, + `WindowsIconNormaliser`, `WindowsShellInteractions`. +- Clicking a CLI card on Home navigates into that CLI (was: switcher-only). +- 18 new Core unit tests cover the new view models and the command flow + (clipboard write, usage bumping, soft-delete, favourite toggle, + new-CLI-with-icon). + ### Added — Phase 3: Shell + read-only browse - `ShellViewModel` owns the cross-cutting shell state: CLI switcher choices, current search text, selected tag (with an "All" sentinel for clean diff --git a/src/Snipdeck.App/Bootstrap.cs b/src/Snipdeck.App/Bootstrap.cs index 6860baf..e6640c7 100644 --- a/src/Snipdeck.App/Bootstrap.cs +++ b/src/Snipdeck.App/Bootstrap.cs @@ -32,15 +32,20 @@ public static IServiceProvider Build() var snipStore = new JsonSnipStore(snipStoreFilePath); var backupService = new BackupService(snipStoreFilePath, backupDirectory, clock); + var iconStorage = new IconAssetStorage(storageDirectory); var services = new ServiceCollection(); _ = services .AddSingleton(pathProvider) .AddSingleton(clock) .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton(settingsStore) .AddSingleton(snipStore) .AddSingleton(backupService) + .AddSingleton(iconStorage) .AddSingleton(config) .AddSingleton() .AddSingleton() diff --git a/src/Snipdeck.App/Controls/CliCard.xaml b/src/Snipdeck.App/Controls/CliCard.xaml index c773461..65d80f9 100644 --- a/src/Snipdeck.App/Controls/CliCard.xaml +++ b/src/Snipdeck.App/Controls/CliCard.xaml @@ -15,7 +15,8 @@ CornerRadius="8" Padding="16" Width="200" - Height="200"> + Height="200" + Tapped="OnTapped"> diff --git a/src/Snipdeck.App/Controls/CliCard.xaml.cs b/src/Snipdeck.App/Controls/CliCard.xaml.cs index 55a9f6d..1784130 100644 --- a/src/Snipdeck.App/Controls/CliCard.xaml.cs +++ b/src/Snipdeck.App/Controls/CliCard.xaml.cs @@ -1,5 +1,8 @@ +using System.Windows.Input; + using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; using Snipdeck.Core.ViewModels; @@ -8,10 +11,11 @@ namespace Snipdeck.App.Controls public sealed partial class CliCard : UserControl { public static readonly DependencyProperty ViewModelProperty = - DependencyProperty.Register( - nameof(ViewModel), - typeof(CliCardViewModel), - typeof(CliCard), + DependencyProperty.Register(nameof(ViewModel), typeof(CliCardViewModel), typeof(CliCard), + new PropertyMetadata(null)); + + public static readonly DependencyProperty NavigateCommandProperty = + DependencyProperty.Register(nameof(NavigateCommand), typeof(ICommand), typeof(CliCard), new PropertyMetadata(null)); public CliCard() @@ -24,5 +28,19 @@ public CliCardViewModel? ViewModel get => (CliCardViewModel?)GetValue(ViewModelProperty); set => SetValue(ViewModelProperty, value); } + + public ICommand? NavigateCommand + { + get => (ICommand?)GetValue(NavigateCommandProperty); + set => SetValue(NavigateCommandProperty, value); + } + + private void OnTapped(object sender, TappedRoutedEventArgs e) + { + if (NavigateCommand?.CanExecute(ViewModel) == true) + { + NavigateCommand.Execute(ViewModel); + } + } } } diff --git a/src/Snipdeck.App/Controls/SnipCard.xaml b/src/Snipdeck.App/Controls/SnipCard.xaml index ca29895..328ae4a 100644 --- a/src/Snipdeck.App/Controls/SnipCard.xaml +++ b/src/Snipdeck.App/Controls/SnipCard.xaml @@ -4,6 +4,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:converters="using:Snipdeck.App.Converters" + xmlns:local="using:Snipdeck.App.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> @@ -36,12 +37,18 @@ Style="{ThemeResource BodyStrongTextBlockStyle}" TextTrimming="CharacterEllipsis" /> - + Padding="6" + Background="Transparent" + BorderThickness="0" + Command="{x:Bind FavouriteCommand, Mode=OneWay}" + CommandParameter="{x:Bind ViewModel, Mode=OneWay}" + ToolTipService.ToolTip="Toggle favourite"> + + + + + diff --git a/src/Snipdeck.App/Controls/SnipCard.xaml.cs b/src/Snipdeck.App/Controls/SnipCard.xaml.cs index 4268a43..37077f9 100644 --- a/src/Snipdeck.App/Controls/SnipCard.xaml.cs +++ b/src/Snipdeck.App/Controls/SnipCard.xaml.cs @@ -1,3 +1,5 @@ +using System.Windows.Input; + using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -8,10 +10,23 @@ namespace Snipdeck.App.Controls public sealed partial class SnipCard : UserControl { public static readonly DependencyProperty ViewModelProperty = - DependencyProperty.Register( - nameof(ViewModel), - typeof(SnipCardViewModel), - typeof(SnipCard), + DependencyProperty.Register(nameof(ViewModel), typeof(SnipCardViewModel), typeof(SnipCard), + new PropertyMetadata(null)); + + public static readonly DependencyProperty CopyCommandProperty = + DependencyProperty.Register(nameof(CopyCommand), typeof(ICommand), typeof(SnipCard), + new PropertyMetadata(null)); + + public static readonly DependencyProperty EditCommandProperty = + DependencyProperty.Register(nameof(EditCommand), typeof(ICommand), typeof(SnipCard), + new PropertyMetadata(null)); + + public static readonly DependencyProperty DeleteCommandProperty = + DependencyProperty.Register(nameof(DeleteCommand), typeof(ICommand), typeof(SnipCard), + new PropertyMetadata(null)); + + public static readonly DependencyProperty FavouriteCommandProperty = + DependencyProperty.Register(nameof(FavouriteCommand), typeof(ICommand), typeof(SnipCard), new PropertyMetadata(null)); public SnipCard() @@ -24,5 +39,31 @@ public SnipCardViewModel? ViewModel get => (SnipCardViewModel?)GetValue(ViewModelProperty); set => SetValue(ViewModelProperty, value); } + + public ICommand? CopyCommand + { + get => (ICommand?)GetValue(CopyCommandProperty); + set => SetValue(CopyCommandProperty, value); + } + + public ICommand? EditCommand + { + get => (ICommand?)GetValue(EditCommandProperty); + set => SetValue(EditCommandProperty, value); + } + + public ICommand? DeleteCommand + { + get => (ICommand?)GetValue(DeleteCommandProperty); + set => SetValue(DeleteCommandProperty, value); + } + + public ICommand? FavouriteCommand + { + get => (ICommand?)GetValue(FavouriteCommandProperty); + set => SetValue(FavouriteCommandProperty, value); + } + + public static string FavouriteGlyph(bool isFavourite) => isFavourite ? "\uE735" : "\uE734"; } } diff --git a/src/Snipdeck.App/Services/WindowsClipboardService.cs b/src/Snipdeck.App/Services/WindowsClipboardService.cs new file mode 100644 index 0000000..724f765 --- /dev/null +++ b/src/Snipdeck.App/Services/WindowsClipboardService.cs @@ -0,0 +1,18 @@ +using Snipdeck.Core.Abstractions; + +using Windows.ApplicationModel.DataTransfer; + +namespace Snipdeck.App.Services +{ + internal sealed class WindowsClipboardService : IClipboardService + { + public Task SetTextAsync(string text) + { + ArgumentNullException.ThrowIfNull(text); + var package = new DataPackage(); + package.SetText(text); + Clipboard.SetContent(package); + return Task.CompletedTask; + } + } +} diff --git a/src/Snipdeck.App/Services/WindowsIconNormaliser.cs b/src/Snipdeck.App/Services/WindowsIconNormaliser.cs new file mode 100644 index 0000000..eba2c06 --- /dev/null +++ b/src/Snipdeck.App/Services/WindowsIconNormaliser.cs @@ -0,0 +1,81 @@ +using Snipdeck.Core.Abstractions; + +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; + +namespace Snipdeck.App.Services +{ + /// + /// Centre-crops, resizes to maxEdgePixels, and re-encodes the + /// source image as PNG. Implemented with Windows.Graphics.Imaging + /// so we don't pull in heavier image libraries. + /// + internal sealed class WindowsIconNormaliser : IIconNormaliser + { + public async Task NormaliseAsync(byte[] sourceBytes, int maxEdgePixels = 256) + { + ArgumentNullException.ThrowIfNull(sourceBytes); + if (maxEdgePixels < 16) + { + throw new ArgumentOutOfRangeException(nameof(maxEdgePixels), maxEdgePixels, "Edge size must be at least 16 pixels."); + } + + using var sourceStream = new InMemoryRandomAccessStream(); + var writer = new DataWriter(sourceStream); + writer.WriteBytes(sourceBytes); + _ = await writer.StoreAsync(); + _ = writer.DetachStream(); + sourceStream.Seek(0); + + var decoder = await BitmapDecoder.CreateAsync(sourceStream); + + var sourceWidth = decoder.PixelWidth; + var sourceHeight = decoder.PixelHeight; + var edge = Math.Min(sourceWidth, sourceHeight); + var offsetX = (sourceWidth - edge) / 2u; + var offsetY = (sourceHeight - edge) / 2u; + var targetEdge = Math.Min((uint)maxEdgePixels, edge); + + var transform = new BitmapTransform + { + Bounds = new BitmapBounds + { + X = offsetX, + Y = offsetY, + Width = edge, + Height = edge, + }, + ScaledWidth = targetEdge, + ScaledHeight = targetEdge, + InterpolationMode = BitmapInterpolationMode.Fant, + }; + + var pixelData = await decoder.GetPixelDataAsync( + BitmapPixelFormat.Bgra8, + BitmapAlphaMode.Premultiplied, + transform, + ExifOrientationMode.RespectExifOrientation, + ColorManagementMode.DoNotColorManage); + + using var outputStream = new InMemoryRandomAccessStream(); + var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, outputStream); + encoder.SetPixelData( + BitmapPixelFormat.Bgra8, + BitmapAlphaMode.Premultiplied, + targetEdge, + targetEdge, + decoder.DpiX, + decoder.DpiY, + pixelData.DetachPixelData()); + await encoder.FlushAsync(); + + outputStream.Seek(0); + var reader = new DataReader(outputStream.GetInputStreamAt(0)); + var length = (uint)outputStream.Size; + _ = await reader.LoadAsync(length); + var bytes = new byte[length]; + reader.ReadBytes(bytes); + return bytes; + } + } +} diff --git a/src/Snipdeck.App/Services/WindowsShellInteractions.cs b/src/Snipdeck.App/Services/WindowsShellInteractions.cs new file mode 100644 index 0000000..64ee006 --- /dev/null +++ b/src/Snipdeck.App/Services/WindowsShellInteractions.cs @@ -0,0 +1,101 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +using Snipdeck.App.Views; +using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Models; +using Snipdeck.Core.ViewModels; + +namespace Snipdeck.App.Services +{ + /// + /// Presents shell-level dialogs as WinUI s. + /// Resolves the parent window lazily so the singleton can be constructed + /// before the main window exists. + /// + internal sealed class WindowsShellInteractions : IShellInteractions + { + private readonly IServiceProvider _services; + private readonly IIconNormaliser _iconNormaliser; + + public WindowsShellInteractions(IServiceProvider services, IIconNormaliser iconNormaliser) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(iconNormaliser); + _services = services; + _iconNormaliser = iconNormaliser; + } + + public async Task ConfirmAsync(string title, string message, string confirmButtonText = "Yes", string cancelButtonText = "Cancel") + { + var dialog = new ContentDialog + { + Title = title, + Content = message, + PrimaryButtonText = confirmButtonText, + CloseButtonText = cancelButtonText, + DefaultButton = ContentDialogButton.Primary, + XamlRoot = GetXamlRoot(), + }; + var result = await dialog.ShowAsync(); + return result == ContentDialogResult.Primary; + } + + public async Task EditSnipAsync(Snip snip, IReadOnlyList availableClis) + { + ArgumentNullException.ThrowIfNull(snip); + var editor = new SnipEditorViewModel(snip); + var dialog = new SnipEditorDialog(editor) + { + XamlRoot = GetXamlRoot(), + }; + var result = await dialog.ShowAsync(); + return result == ContentDialogResult.Primary + ? new SnipEditResult(editor.BuildUpdatedSnip()) + : null; + } + + public async Task EditCliAsync(Cli cli) + { + ArgumentNullException.ThrowIfNull(cli); + var editor = new CliEditorViewModel(cli); + var hwnd = GetMainWindowHandle(); + var dialog = new CliEditorDialog(editor, _iconNormaliser, hwnd) + { + XamlRoot = GetXamlRoot(), + }; + var result = await dialog.ShowAsync(); + return result == ContentDialogResult.Primary + ? new CliEditResult(editor.BuildUpdatedCli(), editor.PickedIconBytes) + : null; + } + + public async Task FillParametersAsync(Snip snip) + { + ArgumentNullException.ThrowIfNull(snip); + var fill = new ParameterFillViewModel(snip); + var dialog = new ParameterFillDialog(fill) + { + XamlRoot = GetXamlRoot(), + }; + var result = await dialog.ShowAsync(); + return result == ContentDialogResult.Primary && fill.IsCopyEnabled + ? new ParameterFillResult(fill.ResolvedCommand) + : null; + } + + private XamlRoot GetXamlRoot() + { + var mainWindow = (MainWindow)_services.GetService(typeof(MainWindow))!; + var content = mainWindow.Content + ?? throw new InvalidOperationException("MainWindow has no content; XamlRoot is unavailable."); + return ((FrameworkElement)content).XamlRoot; + } + + private IntPtr GetMainWindowHandle() + { + var mainWindow = (MainWindow)_services.GetService(typeof(MainWindow))!; + return WinRT.Interop.WindowNative.GetWindowHandle(mainWindow); + } + } +} diff --git a/src/Snipdeck.App/Views/CliEditorDialog.xaml b/src/Snipdeck.App/Views/CliEditorDialog.xaml new file mode 100644 index 0000000..184222e --- /dev/null +++ b/src/Snipdeck.App/Views/CliEditorDialog.xaml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + Text + Choice + + + + + + + + + + + diff --git a/src/Snipdeck.App/Views/SnipEditorDialog.xaml.cs b/src/Snipdeck.App/Views/SnipEditorDialog.xaml.cs new file mode 100644 index 0000000..595151e --- /dev/null +++ b/src/Snipdeck.App/Views/SnipEditorDialog.xaml.cs @@ -0,0 +1,39 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +using Snipdeck.Core.ViewModels; + +namespace Snipdeck.App.Views +{ + public sealed partial class SnipEditorDialog : ContentDialog + { + public SnipEditorDialog(SnipEditorViewModel viewModel) + { + ArgumentNullException.ThrowIfNull(viewModel); + ViewModel = viewModel; + InitializeComponent(); + UpdatePrimaryButtonEnabled(); + viewModel.PropertyChanged += (_, _) => UpdatePrimaryButtonEnabled(); + } + + public SnipEditorViewModel ViewModel { get; } + + private void UpdatePrimaryButtonEnabled() + { + IsPrimaryButtonEnabled = ViewModel.CanSave; + } + + private void OnAddParameterClicked(object sender, RoutedEventArgs e) + { + ViewModel.AddParameter(); + } + + private void OnRemoveParameterClicked(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement element && element.Tag is ParameterEditorRowViewModel row) + { + ViewModel.RemoveParameter(row); + } + } + } +} diff --git a/src/Snipdeck.Core/Abstractions/IClipboardService.cs b/src/Snipdeck.Core/Abstractions/IClipboardService.cs new file mode 100644 index 0000000..a53405c --- /dev/null +++ b/src/Snipdeck.Core/Abstractions/IClipboardService.cs @@ -0,0 +1,7 @@ +namespace Snipdeck.Core.Abstractions +{ + public interface IClipboardService + { + Task SetTextAsync(string text); + } +} diff --git a/src/Snipdeck.Core/Abstractions/IIconAssetStorage.cs b/src/Snipdeck.Core/Abstractions/IIconAssetStorage.cs new file mode 100644 index 0000000..9138863 --- /dev/null +++ b/src/Snipdeck.Core/Abstractions/IIconAssetStorage.cs @@ -0,0 +1,16 @@ +namespace Snipdeck.Core.Abstractions +{ + /// + /// Persists CLI icons inside the data folder. Returns a relative path + /// (e.g. icons/<cli-id>.png) that's safe to store on + /// Cli.IconRef — relative so the folder can move between machines. + /// + public interface IIconAssetStorage + { + Task SaveIconAsync(Guid cliId, byte[] bytes); + + Task DeleteIconAsync(string relativePath); + + string? ResolveAbsolutePath(string? relativePath); + } +} diff --git a/src/Snipdeck.Core/Abstractions/IIconNormaliser.cs b/src/Snipdeck.Core/Abstractions/IIconNormaliser.cs new file mode 100644 index 0000000..2f13932 --- /dev/null +++ b/src/Snipdeck.Core/Abstractions/IIconNormaliser.cs @@ -0,0 +1,13 @@ +namespace Snipdeck.Core.Abstractions +{ + /// + /// Normalises a user-chosen image into a small square icon that can be + /// stored alongside the Snip store. Implementations should cap at the + /// requested edge size (default 256), centre-square-crop, and re-encode + /// to a known format (PNG). + /// + public interface IIconNormaliser + { + Task NormaliseAsync(byte[] sourceBytes, int maxEdgePixels = 256); + } +} diff --git a/src/Snipdeck.Core/Abstractions/IShellInteractions.cs b/src/Snipdeck.Core/Abstractions/IShellInteractions.cs new file mode 100644 index 0000000..a34a77f --- /dev/null +++ b/src/Snipdeck.Core/Abstractions/IShellInteractions.cs @@ -0,0 +1,30 @@ +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.Abstractions +{ + /// + /// Presents shell-level UI (dialogs, confirmations, parameter fill) from + /// Core view models. Implementations live in the App project and use + /// WinUI ContentDialogs; Core stays UI-free. + /// + public interface IShellInteractions + { + Task ConfirmAsync( + string title, + string message, + string confirmButtonText = "Yes", + string cancelButtonText = "Cancel"); + + Task EditSnipAsync(Snip snip, IReadOnlyList availableClis); + + Task EditCliAsync(Cli cli); + + Task FillParametersAsync(Snip snip); + } + + public sealed record SnipEditResult(Snip Snip); + + public sealed record CliEditResult(Cli Cli, byte[]? RawIconBytes); + + public sealed record ParameterFillResult(string ResolvedCommand); +} diff --git a/src/Snipdeck.Core/Services/IconAssetStorage.cs b/src/Snipdeck.Core/Services/IconAssetStorage.cs new file mode 100644 index 0000000..82e4134 --- /dev/null +++ b/src/Snipdeck.Core/Services/IconAssetStorage.cs @@ -0,0 +1,55 @@ +using Snipdeck.Core.Abstractions; + +namespace Snipdeck.Core.Services +{ + /// + /// File-system-backed icon storage. The relative path returned by + /// is always of the form + /// icons/<guid>.png. + /// + public sealed class IconAssetStorage : IIconAssetStorage + { + private const string _iconsSubdirectory = "icons"; + private const string _iconExtension = ".png"; + + private readonly string _baseDirectory; + + public IconAssetStorage(string baseDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory); + _baseDirectory = baseDirectory; + } + + public async Task SaveIconAsync(Guid cliId, byte[] bytes) + { + ArgumentNullException.ThrowIfNull(bytes); + + var directory = Path.Combine(_baseDirectory, _iconsSubdirectory); + _ = Directory.CreateDirectory(directory); + + var fileName = cliId.ToString("N") + _iconExtension; + var absolute = Path.Combine(directory, fileName); + await File.WriteAllBytesAsync(absolute, bytes).ConfigureAwait(false); + + return Path.Combine(_iconsSubdirectory, fileName).Replace('\\', '/'); + } + + public Task DeleteIconAsync(string relativePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + var absolute = ResolveAbsolutePath(relativePath); + if (absolute is not null && File.Exists(absolute)) + { + File.Delete(absolute); + } + return Task.CompletedTask; + } + + public string? ResolveAbsolutePath(string? relativePath) + { + return string.IsNullOrWhiteSpace(relativePath) + ? null + : Path.Combine(_baseDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } + } +} diff --git a/src/Snipdeck.Core/ViewModels/CliEditorViewModel.cs b/src/Snipdeck.Core/ViewModels/CliEditorViewModel.cs new file mode 100644 index 0000000..a273755 --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/CliEditorViewModel.cs @@ -0,0 +1,40 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.ViewModels +{ + public sealed partial class CliEditorViewModel : ObservableObject + { + public CliEditorViewModel(Cli cli) + { + ArgumentNullException.ThrowIfNull(cli); + + Cli = cli; + Name = cli.Name; + } + + public Cli Cli { get; } + + [ObservableProperty] + public partial string Name { get; set; } = string.Empty; + + [ObservableProperty] + public partial byte[]? PickedIconBytes { get; set; } + + [ObservableProperty] + public partial string? PickedIconFileName { get; set; } + + public bool CanSave => !string.IsNullOrWhiteSpace(Name); + + public Cli BuildUpdatedCli() + { + return new Cli + { + Id = Cli.Id, + Name = Name.Trim(), + IconRef = Cli.IconRef, + }; + } + } +} diff --git a/src/Snipdeck.Core/ViewModels/ParameterEditorRowViewModel.cs b/src/Snipdeck.Core/ViewModels/ParameterEditorRowViewModel.cs new file mode 100644 index 0000000..87d3a1a --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/ParameterEditorRowViewModel.cs @@ -0,0 +1,64 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.ViewModels +{ + public sealed partial class ParameterEditorRowViewModel : ObservableObject + { + public ParameterEditorRowViewModel(Parameter parameter) + { + ArgumentNullException.ThrowIfNull(parameter); + + Name = parameter.Name; + Type = parameter.Type; + OptionsText = string.Join(", ", parameter.Options); + Default = parameter.Default ?? string.Empty; + } + + [ObservableProperty] + public partial string Name { get; set; } = string.Empty; + + [ObservableProperty] + public partial ParameterType Type { get; set; } = ParameterType.Text; + + public int TypeIndex + { + get => Type == ParameterType.Choice ? 1 : 0; + set + { + Type = value == 1 ? ParameterType.Choice : ParameterType.Text; + OnPropertyChanged(nameof(IsChoice)); + OnPropertyChanged(nameof(IsText)); + } + } + + public bool IsChoice => Type == ParameterType.Choice; + + public bool IsText => Type == ParameterType.Text; + + [ObservableProperty] + public partial string OptionsText { get; set; } = string.Empty; + + [ObservableProperty] + public partial string Default { get; set; } = string.Empty; + + public Parameter BuildParameter() + { + return new Parameter + { + Name = Name.Trim(), + Type = Type, + Options = ParseList(OptionsText), + Default = string.IsNullOrWhiteSpace(Default) ? null : Default.Trim(), + }; + } + + private static List ParseList(string text) + { + return string.IsNullOrWhiteSpace(text) + ? [] + : [.. text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; + } + } +} diff --git a/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs b/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs new file mode 100644 index 0000000..d30ff12 --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs @@ -0,0 +1,55 @@ +using System.Collections.ObjectModel; + +using CommunityToolkit.Mvvm.ComponentModel; + +using Snipdeck.Core.Engine; +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.ViewModels +{ + public sealed partial class ParameterFillViewModel : ObservableObject + { + private readonly Dictionary _values = new(StringComparer.Ordinal); + + public ParameterFillViewModel(Snip snip) + { + ArgumentNullException.ThrowIfNull(snip); + + Snip = snip; + Inputs = new ObservableCollection( + snip.Parameters.Select(p => new ParameterInputViewModel(p, OnInputValueChanged))); + + foreach (var input in Inputs) + { + _values[input.Name] = input.Value; + } + + UpdateResolution(); + } + + public Snip Snip { get; } + + public ObservableCollection Inputs { get; } + + [ObservableProperty] + public partial string Preview { get; set; } = string.Empty; + + [ObservableProperty] + public partial bool IsCopyEnabled { get; set; } + + public string ResolvedCommand => Preview; + + private void OnInputValueChanged(ParameterInputViewModel input) + { + _values[input.Name] = input.Value; + UpdateResolution(); + } + + private void UpdateResolution() + { + var result = SubstitutionEngine.Substitute(Snip.CommandTemplate, _values); + Preview = result.Text; + IsCopyEnabled = result.IsFullyResolved; + } + } +} diff --git a/src/Snipdeck.Core/ViewModels/ParameterInputViewModel.cs b/src/Snipdeck.Core/ViewModels/ParameterInputViewModel.cs new file mode 100644 index 0000000..7bbdde9 --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/ParameterInputViewModel.cs @@ -0,0 +1,42 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.ViewModels +{ + public sealed partial class ParameterInputViewModel : ObservableObject + { + private readonly Action _onValueChanged; + + public ParameterInputViewModel(Parameter parameter, Action onValueChanged) + { + ArgumentNullException.ThrowIfNull(parameter); + ArgumentNullException.ThrowIfNull(onValueChanged); + + Parameter = parameter; + // Assign the callback first so the Value setter's change handler can use it. + _onValueChanged = onValueChanged; + Value = parameter.Default; + } + + public Parameter Parameter { get; } + + public string Name => Parameter.Name; + + public ParameterType Type => Parameter.Type; + + public bool IsChoice => Parameter.Type == ParameterType.Choice; + + public bool IsText => Parameter.Type == ParameterType.Text; + + public IReadOnlyList Options => Parameter.Options; + + [ObservableProperty] + public partial string? Value { get; set; } + + partial void OnValueChanged(string? value) + { + _onValueChanged(this); + } + } +} diff --git a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs index 3089b41..17bfd7d 100644 --- a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using Snipdeck.Core.Abstractions; using Snipdeck.Core.Models; @@ -10,14 +11,18 @@ namespace Snipdeck.Core.ViewModels { /// /// The shell's view model owns the cross-cutting state — current CLI - /// selection, search text, tag filter, and the content view model - /// currently displayed in the main area. + /// selection, search text, tag filter, content view-model — and exposes + /// the commands that drive authoring (copy, edit, delete, new). /// public sealed partial class ShellViewModel : ObservableObject { public const string AllTagsSentinel = "All"; private readonly ISnipStore _store; + private readonly IClipboardService _clipboard; + private readonly IClock _clock; + private readonly IShellInteractions _interactions; + private readonly IIconAssetStorage _iconStorage; private SnipStoreDocument _document = new(); private bool _suppressShellRefresh; @@ -33,16 +38,32 @@ public sealed partial class ShellViewModel : ObservableObject [ObservableProperty] public partial object? CurrentContent { get; set; } - public ShellViewModel(ISnipStore store) + public ShellViewModel( + ISnipStore store, + IClipboardService clipboard, + IClock clock, + IShellInteractions interactions, + IIconAssetStorage iconStorage) { ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(clipboard); + ArgumentNullException.ThrowIfNull(clock); + ArgumentNullException.ThrowIfNull(interactions); + ArgumentNullException.ThrowIfNull(iconStorage); + _store = store; + _clipboard = clipboard; + _clock = clock; + _interactions = interactions; + _iconStorage = iconStorage; } public ObservableCollection CliChoices { get; } = []; public ObservableCollection Tags { get; } = []; + public bool CanCreateNewSnip => SelectedCliChoice?.Cli is not null; + public async Task LoadAsync(CancellationToken cancellationToken = default) { _document = await _store.LoadAsync(cancellationToken).ConfigureAwait(false); @@ -60,11 +81,187 @@ public void GoHome() SelectedCliChoice = CliChoices.FirstOrDefault(c => c.IsHome); } + [RelayCommand] + private async Task CopySnipAsync(SnipCardViewModel? cardVm) + { + if (cardVm is null) + { + return; + } + var snip = cardVm.Snip; + + string commandToCopy; + if (snip.Parameters.Count == 0) + { + commandToCopy = snip.CommandTemplate; + } + else + { + var result = await _interactions.FillParametersAsync(snip).ConfigureAwait(true); + if (result is null) + { + return; + } + commandToCopy = result.ResolvedCommand; + } + + await _clipboard.SetTextAsync(commandToCopy).ConfigureAwait(true); + snip.UsageCount++; + snip.LastUsedAt = _clock.UtcNow; + await SaveAndRefreshAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task EditSnipAsync(SnipCardViewModel? cardVm) + { + if (cardVm is null) + { + return; + } + var result = await _interactions + .EditSnipAsync(cardVm.Snip, [.. _document.Clis]) + .ConfigureAwait(true); + if (result is null) + { + return; + } + + var index = _document.Snips.FindIndex(s => s.Id == cardVm.Snip.Id); + if (index < 0) + { + return; + } + _document.Snips[index] = result.Snip; + await SaveAndRefreshAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task DeleteSnipAsync(SnipCardViewModel? cardVm) + { + if (cardVm is null) + { + return; + } + var confirmed = await _interactions.ConfirmAsync( + "Delete snip", + $"Move “{cardVm.Snip.Title}” to trash?", + "Delete", + "Cancel").ConfigureAwait(true); + if (!confirmed) + { + return; + } + cardVm.Snip.IsTrash = true; + await SaveAndRefreshAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task ToggleFavouriteAsync(SnipCardViewModel? cardVm) + { + if (cardVm is null) + { + return; + } + cardVm.Snip.IsFavourite = !cardVm.Snip.IsFavourite; + await SaveAndRefreshAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task NewSnipAsync() + { + if (SelectedCliChoice?.Cli is not { } cli) + { + return; + } + var fresh = new Snip { CliId = cli.Id }; + var result = await _interactions + .EditSnipAsync(fresh, [.. _document.Clis]) + .ConfigureAwait(true); + if (result is null) + { + return; + } + + _document.Snips.Add(result.Snip); + await SaveAndRefreshAsync().ConfigureAwait(true); + } + + [RelayCommand] + private async Task NewCliAsync() + { + var fresh = new Cli(); + var result = await _interactions.EditCliAsync(fresh).ConfigureAwait(true); + if (result is null) + { + return; + } + + var saved = result.Cli; + if (result.RawIconBytes is { Length: > 0 } bytes) + { + saved = new Cli + { + Id = saved.Id, + Name = saved.Name, + IconRef = await _iconStorage.SaveIconAsync(saved.Id, bytes).ConfigureAwait(true), + }; + } + + _document.Clis.Add(saved); + await SaveAndRefreshAsync().ConfigureAwait(true); + // Switch to the freshly-created CLI so the user can immediately add Snips. + SelectedCliChoice = CliChoices.FirstOrDefault(c => c.Cli?.Id == saved.Id) ?? SelectedCliChoice; + } + + [RelayCommand] + private void SelectCli(CliCardViewModel? card) + { + if (card is null) + { + return; + } + var choice = CliChoices.FirstOrDefault(c => c.Cli?.Id == card.Id); + if (choice is not null) + { + SelectedCliChoice = choice; + } + } + + [RelayCommand] + private async Task EditCurrentCliAsync() + { + if (SelectedCliChoice?.Cli is not { } current) + { + return; + } + var result = await _interactions.EditCliAsync(current).ConfigureAwait(true); + if (result is null) + { + return; + } + + var updated = result.Cli; + if (result.RawIconBytes is { Length: > 0 } bytes) + { + updated = new Cli + { + Id = updated.Id, + Name = updated.Name, + IconRef = await _iconStorage.SaveIconAsync(updated.Id, bytes).ConfigureAwait(true), + }; + } + + var index = _document.Clis.FindIndex(c => c.Id == current.Id); + if (index < 0) + { + return; + } + _document.Clis[index] = updated; + await SaveAndRefreshAsync().ConfigureAwait(true); + } + partial void OnSelectedCliChoiceChanged(CliChoice? value) { - // Avoid double-applying the shell content: rebuild tags + set the - // default tag without triggering the tag-changed handler, then - // apply the shell content explicitly once. _suppressShellRefresh = true; try { @@ -76,6 +273,7 @@ partial void OnSelectedCliChoiceChanged(CliChoice? value) _suppressShellRefresh = false; } ApplyShellContent(); + OnPropertyChanged(nameof(CanCreateNewSnip)); } partial void OnSelectedTagChanged(string? value) @@ -136,5 +334,26 @@ private void ApplyShellContent() CurrentContent = new HomeViewModel(_document, SearchText); } } + + private async Task SaveAndRefreshAsync() + { + await _store.SaveAsync(_document).ConfigureAwait(true); + var previousCliId = SelectedCliChoice?.Cli?.Id; + + _suppressShellRefresh = true; + try + { + RebuildCliChoices(); + SelectedCliChoice = CliChoices.FirstOrDefault(c => c.Cli?.Id == previousCliId) + ?? CliChoices.FirstOrDefault(); + RebuildTags(); + SelectedTag = Tags.Count > 0 ? AllTagsSentinel : null; + } + finally + { + _suppressShellRefresh = false; + } + ApplyShellContent(); + } } } diff --git a/src/Snipdeck.Core/ViewModels/SnipEditorViewModel.cs b/src/Snipdeck.Core/ViewModels/SnipEditorViewModel.cs new file mode 100644 index 0000000..b08ed37 --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/SnipEditorViewModel.cs @@ -0,0 +1,80 @@ +using System.Collections.ObjectModel; + +using CommunityToolkit.Mvvm.ComponentModel; + +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.ViewModels +{ + public sealed partial class SnipEditorViewModel : ObservableObject + { + public SnipEditorViewModel(Snip snip) + { + ArgumentNullException.ThrowIfNull(snip); + + Snip = snip; + Title = snip.Title; + CommandTemplate = snip.CommandTemplate; + Description = snip.Description ?? string.Empty; + TagsText = string.Join(", ", snip.Tags); + Parameters = new ObservableCollection( + snip.Parameters.Select(p => new ParameterEditorRowViewModel(p))); + } + + public Snip Snip { get; } + + [ObservableProperty] + public partial string Title { get; set; } = string.Empty; + + [ObservableProperty] + public partial string CommandTemplate { get; set; } = string.Empty; + + [ObservableProperty] + public partial string Description { get; set; } = string.Empty; + + [ObservableProperty] + public partial string TagsText { get; set; } = string.Empty; + + public ObservableCollection Parameters { get; } + + public bool CanSave => + !string.IsNullOrWhiteSpace(Title) && !string.IsNullOrWhiteSpace(CommandTemplate); + + public void AddParameter() + { + Parameters.Add(new ParameterEditorRowViewModel(new Parameter { Name = "param" })); + } + + public void RemoveParameter(ParameterEditorRowViewModel row) + { + _ = Parameters.Remove(row); + } + + public Snip BuildUpdatedSnip() + { + return new Snip + { + Id = Snip.Id, + CliId = Snip.CliId, + Title = Title.Trim(), + CommandTemplate = CommandTemplate.Trim(), + Description = string.IsNullOrWhiteSpace(Description) ? null : Description.Trim(), + Tags = ParseTags(TagsText), + IsFavourite = Snip.IsFavourite, + IsTrash = Snip.IsTrash, + UsageCount = Snip.UsageCount, + LastUsedAt = Snip.LastUsedAt, + Parameters = [.. Parameters.Select(r => r.BuildParameter())], + }; + } + + private static List ParseTags(string text) + { + return string.IsNullOrWhiteSpace(text) + ? [] + : [.. text + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase)]; + } + } +} diff --git a/tests/Snipdeck.Core.Tests/Support/FakeClipboardService.cs b/tests/Snipdeck.Core.Tests/Support/FakeClipboardService.cs new file mode 100644 index 0000000..7deb316 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Support/FakeClipboardService.cs @@ -0,0 +1,18 @@ +using Snipdeck.Core.Abstractions; + +namespace Snipdeck.Core.Tests.Support +{ + public sealed class FakeClipboardService : IClipboardService + { + public string? LastText { get; private set; } + + public int SetTextCallCount { get; private set; } + + public Task SetTextAsync(string text) + { + LastText = text; + SetTextCallCount++; + return Task.CompletedTask; + } + } +} diff --git a/tests/Snipdeck.Core.Tests/Support/FakeIconAssetStorage.cs b/tests/Snipdeck.Core.Tests/Support/FakeIconAssetStorage.cs new file mode 100644 index 0000000..81930b8 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Support/FakeIconAssetStorage.cs @@ -0,0 +1,22 @@ +using Snipdeck.Core.Abstractions; + +namespace Snipdeck.Core.Tests.Support +{ + public sealed class FakeIconAssetStorage : IIconAssetStorage + { + public Dictionary Saved { get; } = []; + + public Task SaveIconAsync(Guid cliId, byte[] bytes) + { + Saved[cliId] = bytes; + return Task.FromResult($"icons/{cliId:N}.png"); + } + + public Task DeleteIconAsync(string relativePath) + { + return Task.CompletedTask; + } + + public string? ResolveAbsolutePath(string? relativePath) => relativePath; + } +} diff --git a/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs b/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs new file mode 100644 index 0000000..b329fe0 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs @@ -0,0 +1,53 @@ +using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.Tests.Support +{ + /// + /// Programmable test double for . Set the + /// Next* properties before triggering a command, then inspect the + /// Last* properties after. + /// + public sealed class FakeShellInteractions : IShellInteractions + { + public bool NextConfirmResult { get; set; } + + public SnipEditResult? NextSnipEditResult { get; set; } + + public CliEditResult? NextCliEditResult { get; set; } + + public ParameterFillResult? NextParameterFillResult { get; set; } + + public string? LastConfirmTitle { get; private set; } + + public Snip? LastEditedSnip { get; private set; } + + public Cli? LastEditedCli { get; private set; } + + public Snip? LastFilledSnip { get; private set; } + + public Task ConfirmAsync(string title, string message, string confirmButtonText = "Yes", string cancelButtonText = "Cancel") + { + LastConfirmTitle = title; + return Task.FromResult(NextConfirmResult); + } + + public Task EditSnipAsync(Snip snip, IReadOnlyList availableClis) + { + LastEditedSnip = snip; + return Task.FromResult(NextSnipEditResult); + } + + public Task EditCliAsync(Cli cli) + { + LastEditedCli = cli; + return Task.FromResult(NextCliEditResult); + } + + public Task FillParametersAsync(Snip snip) + { + LastFilledSnip = snip; + return Task.FromResult(NextParameterFillResult); + } + } +} diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs new file mode 100644 index 0000000..6a98463 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs @@ -0,0 +1,87 @@ +using Snipdeck.Core.Models; +using Snipdeck.Core.ViewModels; + +namespace Snipdeck.Core.Tests.ViewModels +{ + public class ParameterFillViewModelTests + { + [Fact] + public void Snip_with_no_parameters_is_immediately_copy_enabled_with_template_as_preview() + { + var snip = new Snip { CommandTemplate = "echo hi" }; + + var vm = new ParameterFillViewModel(snip); + + Assert.True(vm.IsCopyEnabled); + Assert.Equal("echo hi", vm.Preview); + Assert.Empty(vm.Inputs); + } + + [Fact] + public void Defaults_pre_fill_inputs_and_drive_copy_enabled_state() + { + var snip = new Snip + { + CommandTemplate = "echo {name}", + Parameters = [new Parameter { Name = "name", Default = "world" }], + }; + + var vm = new ParameterFillViewModel(snip); + + Assert.True(vm.IsCopyEnabled); + Assert.Equal("echo world", vm.Preview); + } + + [Fact] + public void Missing_value_keeps_copy_disabled_and_leaves_token_in_preview() + { + var snip = new Snip + { + CommandTemplate = "echo {name}", + Parameters = [new Parameter { Name = "name" }], + }; + + var vm = new ParameterFillViewModel(snip); + + Assert.False(vm.IsCopyEnabled); + Assert.Equal("echo {name}", vm.Preview); + } + + [Fact] + public void Editing_an_input_refreshes_the_preview_live() + { + var snip = new Snip + { + CommandTemplate = "deploy {env}", + Parameters = [new Parameter { Name = "env", Default = "dev" }], + }; + + var vm = new ParameterFillViewModel(snip); + Assert.Equal("deploy dev", vm.Preview); + + vm.Inputs[0].Value = "prod"; + + Assert.Equal("deploy prod", vm.Preview); + Assert.True(vm.IsCopyEnabled); + } + + [Fact] + public void Multiple_inputs_resolve_independently() + { + var snip = new Snip + { + CommandTemplate = "git tag -a {tag} -m \"{message}\"", + Parameters = + [ + new Parameter { Name = "tag", Default = "v1.0.0" }, + new Parameter { Name = "message", Default = "Release" }, + ], + }; + + var vm = new ParameterFillViewModel(snip); + + Assert.Equal("git tag -a v1.0.0 -m \"Release\"", vm.Preview); + Assert.True(vm.IsCopyEnabled); + } + } +} diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs new file mode 100644 index 0000000..0cb5fd4 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs @@ -0,0 +1,237 @@ +using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Models; +using Snipdeck.Core.Tests.Support; +using Snipdeck.Core.ViewModels; + +namespace Snipdeck.Core.Tests.ViewModels +{ + public class ShellViewModelCommandsTests + { + private sealed class InMemorySnipStore(SnipStoreDocument document) : ISnipStore + { + public SnipStoreDocument Document { get; private set; } = document; + + public int SaveCount { get; private set; } + + public string FilePath => "in-memory"; + + public Task LoadAsync(CancellationToken cancellationToken = default) => + Task.FromResult(Document); + + public Task SaveAsync(SnipStoreDocument document, CancellationToken cancellationToken = default) + { + Document = document; + SaveCount++; + return Task.CompletedTask; + } + } + + private static async Task<(ShellViewModel vm, InMemorySnipStore store, FakeClipboardService clip, FakeShellInteractions ix, FakeClock clock)> BuildAsync(Action? configure = null) + { + var doc = new SnipStoreDocument(); + configure?.Invoke(doc); + var store = new InMemorySnipStore(doc); + var clip = new FakeClipboardService(); + var ix = new FakeShellInteractions(); + var clock = new FakeClock(new DateTimeOffset(2026, 5, 29, 12, 0, 0, TimeSpan.Zero)); + var vm = new ShellViewModel(store, clip, clock, ix, new FakeIconAssetStorage()); + await vm.LoadAsync(); + return (vm, store, clip, ix, clock); + } + + private static (Cli cli, Snip snip) SeedOneCliOneSnip(SnipStoreDocument doc) + { + var cli = new Cli { Name = "pl-app" }; + var snip = new Snip { CliId = cli.Id, Title = "List", CommandTemplate = "pl-app list" }; + doc.Clis.Add(cli); + doc.Snips.Add(snip); + return (cli, snip); + } + + [Fact] + public async Task CopySnip_with_no_parameters_writes_clipboard_directly_and_bumps_usage() + { + Cli cli = null!; + Snip snip = null!; + var (vm, store, clip, _, clock) = await BuildAsync(d => (cli, snip) = SeedOneCliOneSnip(d)); + + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id); + var card = ((CliViewModel)vm.CurrentContent!).Snips[0]; + + await vm.CopySnipCommand.ExecuteAsync(card); + + Assert.Equal("pl-app list", clip.LastText); + Assert.Equal(1, store.Document.Snips[0].UsageCount); + Assert.Equal(clock.UtcNow, store.Document.Snips[0].LastUsedAt); + } + + [Fact] + public async Task CopySnip_with_parameters_uses_resolved_command_from_interactions() + { + Cli cli = null!; + Snip snip = null!; + var (vm, _, clip, ix, _) = await BuildAsync(d => + { + cli = new Cli { Name = "pl-app" }; + snip = new Snip + { + CliId = cli.Id, + Title = "Echo", + CommandTemplate = "echo {name}", + Parameters = [new Parameter { Name = "name" }], + }; + d.Clis.Add(cli); + d.Snips.Add(snip); + }); + + ix.NextParameterFillResult = new ParameterFillResult("echo hello"); + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id); + var card = ((CliViewModel)vm.CurrentContent!).Snips[0]; + + await vm.CopySnipCommand.ExecuteAsync(card); + + Assert.Equal("echo hello", clip.LastText); + Assert.Same(snip, ix.LastFilledSnip); + } + + [Fact] + public async Task CopySnip_does_nothing_when_user_cancels_the_fill_flyout() + { + Cli cli = null!; + var (vm, store, clip, ix, _) = await BuildAsync(d => + { + cli = new Cli { Name = "pl-app" }; + d.Clis.Add(cli); + d.Snips.Add(new Snip + { + CliId = cli.Id, + Title = "Echo", + CommandTemplate = "echo {name}", + Parameters = [new Parameter { Name = "name" }], + }); + }); + + ix.NextParameterFillResult = null; + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id); + var card = ((CliViewModel)vm.CurrentContent!).Snips[0]; + + await vm.CopySnipCommand.ExecuteAsync(card); + + Assert.Null(clip.LastText); + Assert.Equal(0, store.Document.Snips[0].UsageCount); + } + + [Fact] + public async Task EditSnip_replaces_the_snip_in_the_store_when_interactions_returns_a_result() + { + Cli cli = null!; + Snip snip = null!; + var (vm, store, _, ix, _) = await BuildAsync(d => (cli, snip) = SeedOneCliOneSnip(d)); + + ix.NextSnipEditResult = new SnipEditResult(new Snip + { + Id = snip.Id, + CliId = snip.CliId, + Title = "Renamed", + CommandTemplate = "pl-app list --json", + }); + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id); + var card = ((CliViewModel)vm.CurrentContent!).Snips[0]; + + await vm.EditSnipCommand.ExecuteAsync(card); + + Assert.Equal("Renamed", store.Document.Snips[0].Title); + Assert.Equal("pl-app list --json", store.Document.Snips[0].CommandTemplate); + Assert.Equal(1, store.SaveCount); + } + + [Fact] + public async Task DeleteSnip_marks_as_trash_only_when_confirmed() + { + Cli cli = null!; + Snip snip = null!; + var (vm, store, _, ix, _) = await BuildAsync(d => (cli, snip) = SeedOneCliOneSnip(d)); + + ix.NextConfirmResult = false; + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id); + var card = ((CliViewModel)vm.CurrentContent!).Snips[0]; + + await vm.DeleteSnipCommand.ExecuteAsync(card); + + Assert.False(store.Document.Snips[0].IsTrash); + + ix.NextConfirmResult = true; + await vm.DeleteSnipCommand.ExecuteAsync(card); + + Assert.True(store.Document.Snips[0].IsTrash); + } + + [Fact] + public async Task ToggleFavourite_flips_the_flag_and_saves() + { + Cli cli = null!; + Snip snip = null!; + var (vm, store, _, _, _) = await BuildAsync(d => (cli, snip) = SeedOneCliOneSnip(d)); + + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id); + var card = ((CliViewModel)vm.CurrentContent!).Snips[0]; + + await vm.ToggleFavouriteCommand.ExecuteAsync(card); + Assert.True(store.Document.Snips[0].IsFavourite); + + await vm.ToggleFavouriteCommand.ExecuteAsync(card); + Assert.False(store.Document.Snips[0].IsFavourite); + } + + [Fact] + public async Task NewSnip_only_acts_when_a_CLI_is_selected() + { + Cli cli = null!; + var (vm, store, _, ix, _) = await BuildAsync(d => + { + cli = new Cli { Name = "pl-app" }; + d.Clis.Add(cli); + }); + + ix.NextSnipEditResult = new SnipEditResult(new Snip + { + CliId = cli.Id, + Title = "New", + CommandTemplate = "echo new", + }); + + // No CLI selected (Home) — should no-op + await vm.NewSnipCommand.ExecuteAsync(null); + Assert.Empty(store.Document.Snips); + + // Now select the CLI and try again + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id); + await vm.NewSnipCommand.ExecuteAsync(null); + + Assert.Single(store.Document.Snips); + Assert.Equal("New", store.Document.Snips[0].Title); + } + + [Fact] + public async Task NewCli_adds_the_cli_and_writes_icon_bytes_when_provided() + { + var (vm, store, _, ix, _) = await BuildAsync(); + var icons = new FakeIconAssetStorage(); + var newCli = new Cli { Name = "inv-app" }; + ix.NextCliEditResult = new CliEditResult(newCli, [0x89, 0x50, 0x4E, 0x47]); + + // Replace the icon storage so we can inspect — rebuild the VM + var clip = new FakeClipboardService(); + var clock = new FakeClock(DateTimeOffset.UtcNow); + var vmWithIcons = new ShellViewModel(store, clip, clock, ix, icons); + await vmWithIcons.LoadAsync(); + + await vmWithIcons.NewCliCommand.ExecuteAsync(null); + + Assert.Single(store.Document.Clis); + Assert.Equal("inv-app", store.Document.Clis[0].Name); + Assert.True(icons.Saved.ContainsKey(newCli.Id)); + Assert.StartsWith("icons/", store.Document.Clis[0].IconRef); + } + } +} diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs index 5b58d73..1892f92 100644 --- a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs +++ b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs @@ -1,5 +1,6 @@ using Snipdeck.Core.Abstractions; using Snipdeck.Core.Models; +using Snipdeck.Core.Tests.Support; using Snipdeck.Core.ViewModels; namespace Snipdeck.Core.Tests.ViewModels @@ -22,6 +23,21 @@ public Task SaveAsync(SnipStoreDocument document, CancellationToken cancellation } } + private static ShellViewModel NewShellViewModel( + ISnipStore store, + FakeClipboardService? clipboard = null, + FakeShellInteractions? interactions = null, + FakeIconAssetStorage? icons = null, + FakeClock? clock = null) + { + return new ShellViewModel( + store, + clipboard ?? new FakeClipboardService(), + clock ?? new FakeClock(DateTimeOffset.UtcNow), + interactions ?? new FakeShellInteractions(), + icons ?? new FakeIconAssetStorage()); + } + private static SnipStoreDocument SampleDocument(out Guid plAppId, out Guid mptAppId) { plAppId = Guid.NewGuid(); @@ -48,7 +64,7 @@ private static SnipStoreDocument SampleDocument(out Guid plAppId, out Guid mptAp public async Task After_LoadAsync_home_choice_is_selected_and_content_is_a_HomeViewModel() { var doc = SampleDocument(out _, out _); - var vm = new ShellViewModel(new InMemorySnipStore(doc)); + var vm = NewShellViewModel(new InMemorySnipStore(doc)); await vm.LoadAsync(); @@ -61,7 +77,7 @@ public async Task After_LoadAsync_home_choice_is_selected_and_content_is_a_HomeV public async Task CliChoices_contains_home_followed_by_cli_choices_in_alphabetical_order() { var doc = SampleDocument(out _, out _); - var vm = new ShellViewModel(new InMemorySnipStore(doc)); + var vm = NewShellViewModel(new InMemorySnipStore(doc)); await vm.LoadAsync(); @@ -75,7 +91,7 @@ public async Task CliChoices_contains_home_followed_by_cli_choices_in_alphabetic public async Task Selecting_a_cli_swaps_content_to_a_CliViewModel_and_rebuilds_tags() { var doc = SampleDocument(out var plAppId, out _); - var vm = new ShellViewModel(new InMemorySnipStore(doc)); + var vm = NewShellViewModel(new InMemorySnipStore(doc)); await vm.LoadAsync(); vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == plAppId); @@ -93,7 +109,7 @@ public async Task Selecting_a_cli_swaps_content_to_a_CliViewModel_and_rebuilds_t public async Task Selecting_home_clears_tags_and_resets_to_home_content() { var doc = SampleDocument(out var plAppId, out _); - var vm = new ShellViewModel(new InMemorySnipStore(doc)); + var vm = NewShellViewModel(new InMemorySnipStore(doc)); await vm.LoadAsync(); vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == plAppId); @@ -107,7 +123,7 @@ public async Task Selecting_home_clears_tags_and_resets_to_home_content() public async Task Changing_search_text_rebuilds_content() { var doc = SampleDocument(out _, out _); - var vm = new ShellViewModel(new InMemorySnipStore(doc)); + var vm = NewShellViewModel(new InMemorySnipStore(doc)); await vm.LoadAsync(); var first = vm.CurrentContent; @@ -121,7 +137,7 @@ public async Task Changing_search_text_rebuilds_content() public async Task OpenSettings_swaps_content_for_a_SettingsViewModel() { var doc = SampleDocument(out _, out _); - var vm = new ShellViewModel(new InMemorySnipStore(doc)); + var vm = NewShellViewModel(new InMemorySnipStore(doc)); await vm.LoadAsync(); vm.OpenSettings(); @@ -133,7 +149,7 @@ public async Task OpenSettings_swaps_content_for_a_SettingsViewModel() public async Task Changing_cli_after_OpenSettings_returns_to_shell_content() { var doc = SampleDocument(out var plAppId, out _); - var vm = new ShellViewModel(new InMemorySnipStore(doc)); + var vm = NewShellViewModel(new InMemorySnipStore(doc)); await vm.LoadAsync(); vm.OpenSettings(); diff --git a/tests/Snipdeck.Core.Tests/ViewModels/SnipEditorViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/SnipEditorViewModelTests.cs new file mode 100644 index 0000000..1f6d62f --- /dev/null +++ b/tests/Snipdeck.Core.Tests/ViewModels/SnipEditorViewModelTests.cs @@ -0,0 +1,111 @@ +using Snipdeck.Core.Models; +using Snipdeck.Core.ViewModels; + +namespace Snipdeck.Core.Tests.ViewModels +{ + public class SnipEditorViewModelTests + { + [Fact] + public void Loads_initial_values_from_the_snip() + { + var snip = new Snip + { + Title = "Deploy", + CommandTemplate = "deploy --env {env}", + Description = "Deploys to env", + Tags = ["deploy", "prod"], + Parameters = [new Parameter { Name = "env", Type = ParameterType.Choice, Options = ["dev", "prod"], Default = "dev" }], + }; + + var vm = new SnipEditorViewModel(snip); + + Assert.Equal("Deploy", vm.Title); + Assert.Equal("deploy --env {env}", vm.CommandTemplate); + Assert.Equal("Deploys to env", vm.Description); + Assert.Equal("deploy, prod", vm.TagsText); + Assert.Single(vm.Parameters); + } + + [Fact] + public void CanSave_requires_non_empty_title_and_template() + { + var snip = new Snip { Title = "", CommandTemplate = "" }; + var vm = new SnipEditorViewModel(snip); + Assert.False(vm.CanSave); + + vm.Title = "x"; + Assert.False(vm.CanSave); + + vm.CommandTemplate = "echo"; + Assert.True(vm.CanSave); + + vm.Title = " "; + Assert.False(vm.CanSave); + } + + [Fact] + public void AddParameter_appends_an_editable_row() + { + var vm = new SnipEditorViewModel(new Snip()); + + vm.AddParameter(); + vm.AddParameter(); + + Assert.Equal(2, vm.Parameters.Count); + } + + [Fact] + public void RemoveParameter_removes_the_given_row() + { + var snip = new Snip + { + Parameters = + [ + new Parameter { Name = "a" }, + new Parameter { Name = "b" }, + ], + }; + var vm = new SnipEditorViewModel(snip); + + vm.RemoveParameter(vm.Parameters[0]); + + Assert.Single(vm.Parameters); + Assert.Equal("b", vm.Parameters[0].Name); + } + + [Fact] + public void BuildUpdatedSnip_preserves_identity_and_applies_edits() + { + var snip = new Snip + { + Id = Guid.NewGuid(), + CliId = Guid.NewGuid(), + Title = "Original", + IsFavourite = true, + UsageCount = 3, + }; + var vm = new SnipEditorViewModel(snip) + { + Title = "Updated", + CommandTemplate = "echo {x}", + Description = " ", + TagsText = "alpha, beta, alpha", + }; + vm.AddParameter(); + vm.Parameters[0].Name = "x"; + + var built = vm.BuildUpdatedSnip(); + + Assert.Equal(snip.Id, built.Id); + Assert.Equal(snip.CliId, built.CliId); + Assert.Equal("Updated", built.Title); + Assert.Equal("echo {x}", built.CommandTemplate); + Assert.Null(built.Description); + Assert.Equal(2, built.Tags.Count); + Assert.True(built.IsFavourite); + Assert.Equal(3, built.UsageCount); + Assert.Single(built.Parameters); + Assert.Equal("x", built.Parameters[0].Name); + } + } +}