From 97abcaad766d2c5c4bdaea2665fc3529996e71e4 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 29 May 2026 17:04:33 +0000 Subject: [PATCH 1/2] Phase 3: shell + read-only browse Core view models in Snipdeck.Core/ViewModels/: - ShellViewModel owns the shell's cross-cutting state (CLI switcher choices, search text, selected tag with an "All" sentinel, current content VM). - HomeViewModel builds CLI cards and a most-used Snips list (top 6 by UsageCount, then LastUsedAt desc, hidden when nothing's been used). - CliViewModel exposes the filtered Snip list, favourites bubbled to the top. - SettingsViewModel stub backs the About expander. - CliCardViewModel, SnipCardViewModel, CliChoice support types. Core services: - SnipFilter: pure case-insensitive search (title + template + tags) + tag filter + trash exclusion + DistinctTagsFor. - IdenticonService: Jdenticon-net seeded off Cli.Id so renaming a CLI doesn't change its icon. App-side WinUI: - ShellPage hosts the NavigationView: pane header (AutoSuggestBox + CliChoice ComboBox), pane body tag list (sentinel "All" + per-CLI tags), pane footer Settings button, content area driven by a ShellContentTemplateSelector keyed on view-model type. - Identicon UserControl (DP-driven, lazy BitmapImage from PNG bytes). - CliCard (identicon + name + snip count). - SnipCard (title, monospace template preview, tag chips, favourite star, Copy/Edit/Delete buttons disabled with "Phase 4/5" tooltips). - Settings page stub via SettingsCard/SettingsExpander with About as the last expander; version falls back to assembly InformationalVersion until NBGV lands in Phase 6. - MainWindow now hosts the ShellPage in its content row. Converters: BoolToVisibilityConverter, CountToVisibilityConverter. Tests cover SnipFilter, IdenticonService, ShellViewModel state transitions, HomeViewModel construction. 88 passing (up from 63). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 32 +++ src/Snipdeck.App/Bootstrap.cs | 4 + src/Snipdeck.App/Controls/CliCard.xaml | 49 +++++ src/Snipdeck.App/Controls/CliCard.xaml.cs | 28 +++ src/Snipdeck.App/Controls/Identicon.xaml | 14 ++ src/Snipdeck.App/Controls/Identicon.xaml.cs | 63 ++++++ src/Snipdeck.App/Controls/SnipCard.xaml | 103 ++++++++++ src/Snipdeck.App/Controls/SnipCard.xaml.cs | 28 +++ .../Converters/BoolToVisibilityConverter.cs | 25 +++ .../Converters/CountToVisibilityConverter.cs | 29 +++ src/Snipdeck.App/MainWindow.xaml | 8 +- src/Snipdeck.App/MainWindow.xaml.cs | 6 +- .../Views/ShellContentTemplateSelector.cs | 36 ++++ src/Snipdeck.App/Views/ShellPage.xaml | 193 ++++++++++++++++++ src/Snipdeck.App/Views/ShellPage.xaml.cs | 40 ++++ .../Services/IdenticonService.cs | 27 +++ src/Snipdeck.Core/Services/SnipFilter.cs | 75 +++++++ .../ViewModels/CliCardViewModel.cs | 30 +++ src/Snipdeck.Core/ViewModels/CliChoice.cs | 23 +++ src/Snipdeck.Core/ViewModels/CliViewModel.cs | 34 +++ src/Snipdeck.Core/ViewModels/HomeViewModel.cs | 64 ++++++ .../ViewModels/SettingsViewModel.cs | 32 +++ .../ViewModels/ShellViewModel.cs | 140 +++++++++++++ .../ViewModels/SnipCardViewModel.cs | 32 +++ .../Services/IdenticonServiceTests.cs | 46 +++++ .../Services/SnipFilterTests.cs | 156 ++++++++++++++ .../ViewModels/HomeViewModelTests.cs | 101 +++++++++ .../ViewModels/ShellViewModelTests.cs | 145 +++++++++++++ 28 files changed, 1558 insertions(+), 5 deletions(-) create mode 100644 src/Snipdeck.App/Controls/CliCard.xaml create mode 100644 src/Snipdeck.App/Controls/CliCard.xaml.cs create mode 100644 src/Snipdeck.App/Controls/Identicon.xaml create mode 100644 src/Snipdeck.App/Controls/Identicon.xaml.cs create mode 100644 src/Snipdeck.App/Controls/SnipCard.xaml create mode 100644 src/Snipdeck.App/Controls/SnipCard.xaml.cs create mode 100644 src/Snipdeck.App/Converters/BoolToVisibilityConverter.cs create mode 100644 src/Snipdeck.App/Converters/CountToVisibilityConverter.cs create mode 100644 src/Snipdeck.App/Views/ShellContentTemplateSelector.cs create mode 100644 src/Snipdeck.App/Views/ShellPage.xaml create mode 100644 src/Snipdeck.App/Views/ShellPage.xaml.cs create mode 100644 src/Snipdeck.Core/Services/IdenticonService.cs create mode 100644 src/Snipdeck.Core/Services/SnipFilter.cs create mode 100644 src/Snipdeck.Core/ViewModels/CliCardViewModel.cs create mode 100644 src/Snipdeck.Core/ViewModels/CliChoice.cs create mode 100644 src/Snipdeck.Core/ViewModels/CliViewModel.cs create mode 100644 src/Snipdeck.Core/ViewModels/HomeViewModel.cs create mode 100644 src/Snipdeck.Core/ViewModels/SettingsViewModel.cs create mode 100644 src/Snipdeck.Core/ViewModels/ShellViewModel.cs create mode 100644 src/Snipdeck.Core/ViewModels/SnipCardViewModel.cs create mode 100644 tests/Snipdeck.Core.Tests/Services/IdenticonServiceTests.cs create mode 100644 tests/Snipdeck.Core.Tests/Services/SnipFilterTests.cs create mode 100644 tests/Snipdeck.Core.Tests/ViewModels/HomeViewModelTests.cs create mode 100644 tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 687d41b..ce2e3d1 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 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 + filter-off semantics), and the active content view model. +- `HomeViewModel` builds the home view's CLI cards (alphabetical, with snip + counts) and the most-used Snips list (top 6 by `UsageCount`, then + `LastUsedAt` desc; hidden when nothing's been used yet). +- `CliViewModel` exposes the filtered Snip list for a single CLI, favourites + bubbled to the top. +- `SettingsViewModel` stub — populates the About expander; real settings UI + arrives in Phase 6. +- `SnipFilter` pure helpers: case-insensitive search across title / template / + tags, tag filter, trash exclusion, `DistinctTagsFor` for the pane tag list. +- `IdenticonService` (Jdenticon-net) — generates deterministic identicon PNG + bytes seeded off `Cli.Id` so renaming a CLI doesn't change its icon. +- `ShellPage` (WinUI): `NavigationView` with a custom pane header + (`AutoSuggestBox` search + CLI switcher `ComboBox`), pane body tag list, + pane footer Settings button, content area driven by a + `ShellContentTemplateSelector` that picks the right `DataTemplate` based on + the current content view-model type. +- Custom user controls: `Identicon` (dependency-property-driven, lazy image + load), `CliCard` (identicon + name + snip count), `SnipCard` (title, + monospace template preview, tag chips, favourite star, disabled + Copy/Edit/Delete buttons with "Phase 4/5" tooltips). +- Settings page stub uses `SettingsCard` / `SettingsExpander` with About as the + last expander; About shows app name, copyright, and version (the version + string falls back to the assembly's `InformationalVersion` until Phase 6 + wires Nerdbank.GitVersioning). +- `MainWindow` now hosts the `ShellPage` in its content row; the custom title + bar and Mica backdrop carry over from Phase 2. +- Converters: `BoolToVisibilityConverter`, `CountToVisibilityConverter`. + ### Added — Phase 2: App lifecycle skeleton - Explicit `Program.cs` entry point that runs the Velopack hook → initialises WinRT COM wrappers → checks single-instance via Windows App SDK diff --git a/src/Snipdeck.App/Bootstrap.cs b/src/Snipdeck.App/Bootstrap.cs index cf2acca..6860baf 100644 --- a/src/Snipdeck.App/Bootstrap.cs +++ b/src/Snipdeck.App/Bootstrap.cs @@ -1,9 +1,11 @@ using Microsoft.Extensions.DependencyInjection; using Snipdeck.App.Services; +using Snipdeck.App.Views; using Snipdeck.Core.Abstractions; using Snipdeck.Core.Models; using Snipdeck.Core.Services; +using Snipdeck.Core.ViewModels; namespace Snipdeck.App { @@ -40,6 +42,8 @@ public static IServiceProvider Build() .AddSingleton(snipStore) .AddSingleton(backupService) .AddSingleton(config) + .AddSingleton() + .AddSingleton() .AddSingleton(); return services.BuildServiceProvider(); diff --git a/src/Snipdeck.App/Controls/CliCard.xaml b/src/Snipdeck.App/Controls/CliCard.xaml new file mode 100644 index 0000000..c773461 --- /dev/null +++ b/src/Snipdeck.App/Controls/CliCard.xaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Snipdeck.App/Controls/CliCard.xaml.cs b/src/Snipdeck.App/Controls/CliCard.xaml.cs new file mode 100644 index 0000000..55a9f6d --- /dev/null +++ b/src/Snipdeck.App/Controls/CliCard.xaml.cs @@ -0,0 +1,28 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +using Snipdeck.Core.ViewModels; + +namespace Snipdeck.App.Controls +{ + public sealed partial class CliCard : UserControl + { + public static readonly DependencyProperty ViewModelProperty = + DependencyProperty.Register( + nameof(ViewModel), + typeof(CliCardViewModel), + typeof(CliCard), + new PropertyMetadata(null)); + + public CliCard() + { + InitializeComponent(); + } + + public CliCardViewModel? ViewModel + { + get => (CliCardViewModel?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + } +} diff --git a/src/Snipdeck.App/Controls/Identicon.xaml b/src/Snipdeck.App/Controls/Identicon.xaml new file mode 100644 index 0000000..5f1e95e --- /dev/null +++ b/src/Snipdeck.App/Controls/Identicon.xaml @@ -0,0 +1,14 @@ + + + + + diff --git a/src/Snipdeck.App/Controls/Identicon.xaml.cs b/src/Snipdeck.App/Controls/Identicon.xaml.cs new file mode 100644 index 0000000..0c08943 --- /dev/null +++ b/src/Snipdeck.App/Controls/Identicon.xaml.cs @@ -0,0 +1,63 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; + +using Snipdeck.Core.Services; + +using Windows.Storage.Streams; + +namespace Snipdeck.App.Controls +{ + /// + /// Renders an identicon from a seed. The seed should be + /// the immutable Cli.Id so renaming a CLI doesn't change its icon. + /// + public sealed partial class Identicon : UserControl + { + public static readonly DependencyProperty SeedProperty = + DependencyProperty.Register( + nameof(Seed), + typeof(Guid), + typeof(Identicon), + new PropertyMetadata(Guid.Empty, OnSeedChanged)); + + public Identicon() + { + InitializeComponent(); + } + + public Guid Seed + { + get => (Guid)GetValue(SeedProperty); + set => SetValue(SeedProperty, value); + } + + private static void OnSeedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is Identicon icon) + { + _ = icon.UpdateImageAsync(); + } + } + + private async Task UpdateImageAsync() + { + if (Seed == Guid.Empty) + { + IconImage.Source = null; + return; + } + + var bytes = IdenticonService.GeneratePng(Seed); + var image = new BitmapImage(); + using var stream = new InMemoryRandomAccessStream(); + var writer = new DataWriter(stream); + writer.WriteBytes(bytes); + _ = await writer.StoreAsync(); + _ = writer.DetachStream(); + stream.Seek(0); + await image.SetSourceAsync(stream); + IconImage.Source = image; + } + } +} diff --git a/src/Snipdeck.App/Controls/SnipCard.xaml b/src/Snipdeck.App/Controls/SnipCard.xaml new file mode 100644 index 0000000..ca29895 --- /dev/null +++ b/src/Snipdeck.App/Controls/SnipCard.xaml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Snipdeck.App/Controls/SnipCard.xaml.cs b/src/Snipdeck.App/Controls/SnipCard.xaml.cs new file mode 100644 index 0000000..4268a43 --- /dev/null +++ b/src/Snipdeck.App/Controls/SnipCard.xaml.cs @@ -0,0 +1,28 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +using Snipdeck.Core.ViewModels; + +namespace Snipdeck.App.Controls +{ + public sealed partial class SnipCard : UserControl + { + public static readonly DependencyProperty ViewModelProperty = + DependencyProperty.Register( + nameof(ViewModel), + typeof(SnipCardViewModel), + typeof(SnipCard), + new PropertyMetadata(null)); + + public SnipCard() + { + InitializeComponent(); + } + + public SnipCardViewModel? ViewModel + { + get => (SnipCardViewModel?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + } +} diff --git a/src/Snipdeck.App/Converters/BoolToVisibilityConverter.cs b/src/Snipdeck.App/Converters/BoolToVisibilityConverter.cs new file mode 100644 index 0000000..1c7d54c --- /dev/null +++ b/src/Snipdeck.App/Converters/BoolToVisibilityConverter.cs @@ -0,0 +1,25 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace Snipdeck.App.Converters +{ + public sealed class BoolToVisibilityConverter : IValueConverter + { + public bool Invert { get; set; } + + public object Convert(object value, Type targetType, object parameter, string language) + { + var flag = value is bool b && b; + if (Invert) + { + flag = !flag; + } + return flag ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/Snipdeck.App/Converters/CountToVisibilityConverter.cs b/src/Snipdeck.App/Converters/CountToVisibilityConverter.cs new file mode 100644 index 0000000..75d7f6e --- /dev/null +++ b/src/Snipdeck.App/Converters/CountToVisibilityConverter.cs @@ -0,0 +1,29 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace Snipdeck.App.Converters +{ + /// + /// Visible when the bound integer is greater than zero. Used to hide + /// sections such as "Most used" when the underlying collection is empty. + /// + public sealed class CountToVisibilityConverter : IValueConverter + { + public bool Invert { get; set; } + + public object Convert(object value, Type targetType, object parameter, string language) + { + var hasItems = value is int count && count > 0; + if (Invert) + { + hasItems = !hasItems; + } + return hasItems ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/Snipdeck.App/MainWindow.xaml b/src/Snipdeck.App/MainWindow.xaml index 2450ae0..39a7208 100644 --- a/src/Snipdeck.App/MainWindow.xaml +++ b/src/Snipdeck.App/MainWindow.xaml @@ -32,9 +32,9 @@ - - - + diff --git a/src/Snipdeck.App/MainWindow.xaml.cs b/src/Snipdeck.App/MainWindow.xaml.cs index 19c6be6..9bcad90 100644 --- a/src/Snipdeck.App/MainWindow.xaml.cs +++ b/src/Snipdeck.App/MainWindow.xaml.cs @@ -1,20 +1,24 @@ using Microsoft.UI.Xaml; +using Snipdeck.App.Views; using Snipdeck.Core.Models; namespace Snipdeck.App { public sealed partial class MainWindow : Window { - public MainWindow(AppConfig config) + public MainWindow(AppConfig config, ShellPage shellPage) { ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(shellPage); InitializeComponent(); ExtendsContentIntoTitleBar = true; SetTitleBar(AppTitleBar); + ShellHost.Content = shellPage; + ApplyTheme(config.Theme); } diff --git a/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs b/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs new file mode 100644 index 0000000..6d7cd9a --- /dev/null +++ b/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs @@ -0,0 +1,36 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +using Snipdeck.Core.ViewModels; + +namespace Snipdeck.App.Views +{ + /// + /// Picks the right for the shell's content area + /// based on the view-model type held by ShellViewModel.CurrentContent. + /// + public sealed class ShellContentTemplateSelector : DataTemplateSelector + { + public DataTemplate? HomeTemplate { get; set; } + + public DataTemplate? CliTemplate { get; set; } + + public DataTemplate? SettingsTemplate { get; set; } + + protected override DataTemplate? SelectTemplateCore(object item) + { + return item switch + { + HomeViewModel => HomeTemplate, + CliViewModel => CliTemplate, + SettingsViewModel => SettingsTemplate, + _ => null, + }; + } + + protected override DataTemplate? SelectTemplateCore(object item, DependencyObject container) + { + return SelectTemplateCore(item); + } + } +} diff --git a/src/Snipdeck.App/Views/ShellPage.xaml b/src/Snipdeck.App/Views/ShellPage.xaml new file mode 100644 index 0000000..80b6c84 --- /dev/null +++ b/src/Snipdeck.App/Views/ShellPage.xaml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Snipdeck.App/Views/ShellPage.xaml.cs b/src/Snipdeck.App/Views/ShellPage.xaml.cs new file mode 100644 index 0000000..1bb6ac4 --- /dev/null +++ b/src/Snipdeck.App/Views/ShellPage.xaml.cs @@ -0,0 +1,40 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +using Snipdeck.Core.ViewModels; + +namespace Snipdeck.App.Views +{ + public sealed partial class ShellPage : UserControl + { + public ShellPage(ShellViewModel viewModel) + { + ArgumentNullException.ThrowIfNull(viewModel); + ViewModel = viewModel; + InitializeComponent(); + Loaded += OnLoaded; + } + + public ShellViewModel ViewModel { get; } + + private async void OnLoaded(object sender, RoutedEventArgs e) + { + await ViewModel.LoadAsync(); + } + + private void OnSettingsClicked(object sender, RoutedEventArgs e) + { + ViewModel.OpenSettings(); + } + + private void OnNavigationSelectionChanged( + NavigationView sender, + NavigationViewSelectionChangedEventArgs args) + { + if (args.SelectedItem is string tag) + { + ViewModel.SelectedTag = tag; + } + } + } +} diff --git a/src/Snipdeck.Core/Services/IdenticonService.cs b/src/Snipdeck.Core/Services/IdenticonService.cs new file mode 100644 index 0000000..e9761aa --- /dev/null +++ b/src/Snipdeck.Core/Services/IdenticonService.cs @@ -0,0 +1,27 @@ +using Jdenticon; + +namespace Snipdeck.Core.Services +{ + /// + /// Generates identicon PNG bytes from a seed. The seed + /// must be the immutable so renaming a CLI + /// doesn't change its icon — recognisability is the whole point. + /// + public static class IdenticonService + { + public const int DefaultSize = 128; + + public static byte[] GeneratePng(Guid seed, int size = DefaultSize) + { + if (size < 16) + { + throw new ArgumentOutOfRangeException(nameof(size), size, "Identicon size must be at least 16 pixels."); + } + + var identicon = Identicon.FromValue(seed.ToString("N"), size); + using var memory = new MemoryStream(); + identicon.SaveAsPng(memory); + return memory.ToArray(); + } + } +} diff --git a/src/Snipdeck.Core/Services/SnipFilter.cs b/src/Snipdeck.Core/Services/SnipFilter.cs new file mode 100644 index 0000000..53f02b7 --- /dev/null +++ b/src/Snipdeck.Core/Services/SnipFilter.cs @@ -0,0 +1,75 @@ +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.Services +{ + /// + /// Pure filter helpers used by view models when search text or tag scope + /// changes. Implemented as a standalone static class so they can be unit + /// tested without any view-model plumbing. + /// + public static class SnipFilter + { + public static IEnumerable Apply( + IEnumerable snips, + string? searchText, + string? selectedTag, + bool includeTrash = false) + { + ArgumentNullException.ThrowIfNull(snips); + + var search = string.IsNullOrWhiteSpace(searchText) ? null : searchText.Trim(); + var tag = string.IsNullOrWhiteSpace(selectedTag) ? null : selectedTag.Trim(); + + foreach (var snip in snips) + { + if (!includeTrash && snip.IsTrash) + { + continue; + } + if (tag is not null && !snip.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + if (search is not null && !MatchesSearch(snip, search)) + { + continue; + } + yield return snip; + } + } + + public static IEnumerable DistinctTagsFor(IEnumerable snips) + { + ArgumentNullException.ThrowIfNull(snips); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var snip in snips) + { + if (snip.IsTrash) + { + continue; + } + foreach (var tag in snip.Tags) + { + if (!string.IsNullOrWhiteSpace(tag) && seen.Add(tag)) + { + yield return tag; + } + } + } + } + + private static bool MatchesSearch(Snip snip, string search) + { + return ContainsOrdinalIgnoreCase(snip.Title, search) + || ContainsOrdinalIgnoreCase(snip.CommandTemplate, search) + || snip.Tags.Any(t => ContainsOrdinalIgnoreCase(t, search)); + } + + private static bool ContainsOrdinalIgnoreCase(string? haystack, string needle) + { + return haystack is not null + && haystack.Contains(needle, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/Snipdeck.Core/ViewModels/CliCardViewModel.cs b/src/Snipdeck.Core/ViewModels/CliCardViewModel.cs new file mode 100644 index 0000000..aa6df16 --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/CliCardViewModel.cs @@ -0,0 +1,30 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.ViewModels +{ + public sealed partial class CliCardViewModel : ObservableObject + { + public CliCardViewModel(Cli cli, int snipCount) + { + ArgumentNullException.ThrowIfNull(cli); + Cli = cli; + SnipCount = snipCount; + } + + public Cli Cli { get; } + + public Guid Id => Cli.Id; + + public string Name => Cli.Name; + + public string? IconRef => Cli.IconRef; + + public int SnipCount { get; } + + public string SnipCountDisplay => SnipCount == 1 + ? "1 snip" + : $"{SnipCount} snips"; + } +} diff --git a/src/Snipdeck.Core/ViewModels/CliChoice.cs b/src/Snipdeck.Core/ViewModels/CliChoice.cs new file mode 100644 index 0000000..4e20741 --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/CliChoice.cs @@ -0,0 +1,23 @@ +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.ViewModels +{ + /// + /// One entry in the CLI switcher dropdown. is true for + /// the synthetic "All / Home" entry that sits at the top of the list; for + /// every other entry points at a real . + /// + public sealed class CliChoice + { + public Cli? Cli { get; init; } + + public string Display { get; init; } = string.Empty; + + public bool IsHome => Cli is null; + + public override string ToString() + { + return Display; + } + } +} diff --git a/src/Snipdeck.Core/ViewModels/CliViewModel.cs b/src/Snipdeck.Core/ViewModels/CliViewModel.cs new file mode 100644 index 0000000..d242818 --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/CliViewModel.cs @@ -0,0 +1,34 @@ +using System.Collections.ObjectModel; + +using CommunityToolkit.Mvvm.ComponentModel; + +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.ViewModels +{ + public sealed partial class CliViewModel : ObservableObject + { + public CliViewModel(Cli cli, IEnumerable filteredSnips) + { + ArgumentNullException.ThrowIfNull(cli); + ArgumentNullException.ThrowIfNull(filteredSnips); + + Cli = cli; + Snips = new ObservableCollection( + filteredSnips + .OrderByDescending(s => s.IsFavourite) + .ThenBy(s => s.Title, StringComparer.OrdinalIgnoreCase) + .Select(s => new SnipCardViewModel(s))); + } + + public Cli Cli { get; } + + public string Name => Cli.Name; + + public ObservableCollection Snips { get; } + + public bool HasSnips => Snips.Count > 0; + + public bool IsEmpty => Snips.Count == 0; + } +} diff --git a/src/Snipdeck.Core/ViewModels/HomeViewModel.cs b/src/Snipdeck.Core/ViewModels/HomeViewModel.cs new file mode 100644 index 0000000..4cfde9c --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/HomeViewModel.cs @@ -0,0 +1,64 @@ +using System.Collections.ObjectModel; + +using CommunityToolkit.Mvvm.ComponentModel; + +using Snipdeck.Core.Models; +using Snipdeck.Core.Services; + +namespace Snipdeck.Core.ViewModels +{ + public sealed partial class HomeViewModel : ObservableObject + { + public const int MostUsedLimit = 6; + + public HomeViewModel(SnipStoreDocument document, string? searchText) + { + ArgumentNullException.ThrowIfNull(document); + + CliCards = new ObservableCollection(BuildCliCards(document, searchText)); + MostUsedSnips = new ObservableCollection(BuildMostUsedSnips(document, searchText)); + } + + public ObservableCollection CliCards { get; } + + public ObservableCollection MostUsedSnips { get; } + + public bool HasCliCards => CliCards.Count > 0; + + public bool HasMostUsedSnips => MostUsedSnips.Count > 0; + + private static IEnumerable BuildCliCards(SnipStoreDocument document, string? searchText) + { + var snipsByCli = document.Snips + .Where(s => !s.IsTrash) + .GroupBy(s => s.CliId) + .ToDictionary(g => g.Key, g => g.Count()); + + var matcher = string.IsNullOrWhiteSpace(searchText) + ? null + : searchText.Trim(); + + foreach (var cli in document.Clis.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)) + { + if (matcher is not null + && !cli.Name.Contains(matcher, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + _ = snipsByCli.TryGetValue(cli.Id, out var count); + yield return new CliCardViewModel(cli, count); + } + } + + private static IEnumerable BuildMostUsedSnips(SnipStoreDocument document, string? searchText) + { + var filtered = SnipFilter.Apply(document.Snips, searchText, selectedTag: null); + return filtered + .Where(s => s.UsageCount > 0) + .OrderByDescending(s => s.UsageCount) + .ThenByDescending(s => s.LastUsedAt ?? DateTimeOffset.MinValue) + .Take(MostUsedLimit) + .Select(s => new SnipCardViewModel(s)); + } + } +} diff --git a/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs b/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..d03a798 --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs @@ -0,0 +1,32 @@ +using System.Reflection; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Snipdeck.Core.ViewModels +{ + /// + /// Settings stub for Phase 3 — real settings UI (storage path, backups, + /// theme, hotkey, close behaviour) lands in Phase 6, About becomes a + /// fully-fledged expander backed by Nerdbank.GitVersioning then. + /// + public sealed partial class SettingsViewModel : ObservableObject + { + public SettingsViewModel() + { + var assembly = typeof(SettingsViewModel).Assembly; + VersionDisplay = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "0.0.0-dev"; + CopyrightDisplay = assembly.GetCustomAttribute()?.Copyright + ?? "Copyright © Stuart Meeks"; + } + + public static string AppName => "Snipdeck"; + + public string VersionDisplay { get; } + + public string CopyrightDisplay { get; } + + public static string TaglineDisplay => "Parameterised CLI snippet manager."; + } +} diff --git a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs new file mode 100644 index 0000000..3089b41 --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs @@ -0,0 +1,140 @@ +using System.Collections.ObjectModel; + +using CommunityToolkit.Mvvm.ComponentModel; + +using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Models; +using Snipdeck.Core.Services; + +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. + /// + public sealed partial class ShellViewModel : ObservableObject + { + public const string AllTagsSentinel = "All"; + + private readonly ISnipStore _store; + private SnipStoreDocument _document = new(); + private bool _suppressShellRefresh; + + [ObservableProperty] + public partial string SearchText { get; set; } = string.Empty; + + [ObservableProperty] + public partial CliChoice? SelectedCliChoice { get; set; } + + [ObservableProperty] + public partial string? SelectedTag { get; set; } + + [ObservableProperty] + public partial object? CurrentContent { get; set; } + + public ShellViewModel(ISnipStore store) + { + ArgumentNullException.ThrowIfNull(store); + _store = store; + } + + public ObservableCollection CliChoices { get; } = []; + + public ObservableCollection Tags { get; } = []; + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + _document = await _store.LoadAsync(cancellationToken).ConfigureAwait(false); + RebuildCliChoices(); + SelectedCliChoice = CliChoices.FirstOrDefault(); + } + + public void OpenSettings() + { + CurrentContent = new SettingsViewModel(); + } + + public void GoHome() + { + SelectedCliChoice = CliChoices.FirstOrDefault(c => c.IsHome); + } + + 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 + { + RebuildTags(); + SelectedTag = Tags.Count > 0 ? AllTagsSentinel : null; + } + finally + { + _suppressShellRefresh = false; + } + ApplyShellContent(); + } + + partial void OnSelectedTagChanged(string? value) + { + if (_suppressShellRefresh) + { + return; + } + ApplyShellContent(); + } + + partial void OnSearchTextChanged(string value) + { + if (_suppressShellRefresh) + { + return; + } + ApplyShellContent(); + } + + private void RebuildCliChoices() + { + CliChoices.Clear(); + CliChoices.Add(new CliChoice { Display = "All / Home" }); + foreach (var cli in _document.Clis.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)) + { + CliChoices.Add(new CliChoice { Cli = cli, Display = cli.Name }); + } + } + + private void RebuildTags() + { + Tags.Clear(); + if (SelectedCliChoice?.Cli is not { } cli) + { + return; + } + var snipsForCli = _document.Snips.Where(s => s.CliId == cli.Id); + Tags.Add(AllTagsSentinel); + foreach (var tag in SnipFilter.DistinctTagsFor(snipsForCli) + .OrderBy(t => t, StringComparer.OrdinalIgnoreCase)) + { + Tags.Add(tag); + } + } + + private void ApplyShellContent() + { + if (SelectedCliChoice?.Cli is { } cli) + { + var cliSnips = _document.Snips.Where(s => s.CliId == cli.Id); + var effectiveTag = SelectedTag == AllTagsSentinel ? null : SelectedTag; + var filtered = SnipFilter.Apply(cliSnips, SearchText, effectiveTag).ToList(); + CurrentContent = new CliViewModel(cli, filtered); + } + else + { + CurrentContent = new HomeViewModel(_document, SearchText); + } + } + } +} diff --git a/src/Snipdeck.Core/ViewModels/SnipCardViewModel.cs b/src/Snipdeck.Core/ViewModels/SnipCardViewModel.cs new file mode 100644 index 0000000..1d7720f --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/SnipCardViewModel.cs @@ -0,0 +1,32 @@ +using System.Collections.ObjectModel; + +using CommunityToolkit.Mvvm.ComponentModel; + +using Snipdeck.Core.Models; + +namespace Snipdeck.Core.ViewModels +{ + public sealed partial class SnipCardViewModel : ObservableObject + { + public SnipCardViewModel(Snip snip) + { + ArgumentNullException.ThrowIfNull(snip); + Snip = snip; + Tags = new ObservableCollection(snip.Tags); + } + + public Snip Snip { get; } + + public Guid Id => Snip.Id; + + public string Title => Snip.Title; + + public string CommandTemplate => Snip.CommandTemplate; + + public bool IsFavourite => Snip.IsFavourite; + + public int UsageCount => Snip.UsageCount; + + public ObservableCollection Tags { get; } + } +} diff --git a/tests/Snipdeck.Core.Tests/Services/IdenticonServiceTests.cs b/tests/Snipdeck.Core.Tests/Services/IdenticonServiceTests.cs new file mode 100644 index 0000000..92df78c --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Services/IdenticonServiceTests.cs @@ -0,0 +1,46 @@ +using Snipdeck.Core.Services; + +namespace Snipdeck.Core.Tests.Services +{ + public class IdenticonServiceTests + { + [Fact] + public void GeneratePng_returns_a_valid_png_signature() + { + var bytes = IdenticonService.GeneratePng(Guid.NewGuid()); + + // PNG signature: 89 50 4E 47 0D 0A 1A 0A + Assert.True(bytes.Length > 8); + Assert.Equal(0x89, bytes[0]); + Assert.Equal((byte)'P', bytes[1]); + Assert.Equal((byte)'N', bytes[2]); + Assert.Equal((byte)'G', bytes[3]); + } + + [Fact] + public void GeneratePng_is_deterministic_for_a_given_seed() + { + var seed = Guid.Parse("a4d1c0e1-0000-0000-0000-000000000000"); + + var first = IdenticonService.GeneratePng(seed); + var second = IdenticonService.GeneratePng(seed); + + Assert.Equal(first, second); + } + + [Fact] + public void GeneratePng_differs_between_seeds() + { + var a = IdenticonService.GeneratePng(Guid.Parse("11111111-1111-1111-1111-111111111111")); + var b = IdenticonService.GeneratePng(Guid.Parse("22222222-2222-2222-2222-222222222222")); + + Assert.NotEqual(a, b); + } + + [Fact] + public void GeneratePng_throws_on_too_small_size() + { + Assert.Throws(() => IdenticonService.GeneratePng(Guid.NewGuid(), size: 8)); + } + } +} diff --git a/tests/Snipdeck.Core.Tests/Services/SnipFilterTests.cs b/tests/Snipdeck.Core.Tests/Services/SnipFilterTests.cs new file mode 100644 index 0000000..bb0eb04 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Services/SnipFilterTests.cs @@ -0,0 +1,156 @@ +using Snipdeck.Core.Models; +using Snipdeck.Core.Services; + +namespace Snipdeck.Core.Tests.Services +{ + public class SnipFilterTests + { + private static Snip Snip(string title, string template = "echo", string[]? tags = null, bool isTrash = false) + { + return new Snip + { + Title = title, + CommandTemplate = template, + IsTrash = isTrash, + Tags = tags is null ? [] : [.. tags], + }; + } + + [Fact] + public void Empty_search_and_tag_returns_all_non_trash_snips() + { + var snips = new[] + { + Snip("a"), + Snip("b"), + Snip("c", isTrash: true), + }; + + var result = SnipFilter.Apply(snips, searchText: null, selectedTag: null).ToList(); + + Assert.Equal(2, result.Count); + Assert.DoesNotContain(result, s => s.Title == "c"); + } + + [Fact] + public void Trash_can_be_included_when_explicitly_requested() + { + var snips = new[] + { + Snip("a"), + Snip("b", isTrash: true), + }; + + var result = SnipFilter.Apply(snips, null, null, includeTrash: true).ToList(); + + Assert.Equal(2, result.Count); + } + + [Fact] + public void Search_matches_title_substring_case_insensitively() + { + var snips = new[] + { + Snip("List Organisations"), + Snip("Deploy production"), + }; + + var result = SnipFilter.Apply(snips, "ORG", null).ToList(); + + var single = Assert.Single(result); + Assert.Equal("List Organisations", single.Title); + } + + [Fact] + public void Search_matches_command_template() + { + var snips = new[] + { + Snip("a", template: "pl-app orgs list"), + Snip("b", template: "mpt-app users list"), + }; + + var result = SnipFilter.Apply(snips, "users", null).ToList(); + + Assert.Single(result); + Assert.Equal("b", result[0].Title); + } + + [Fact] + public void Search_matches_tag() + { + var snips = new[] + { + Snip("a", tags: ["deploy", "prod"]), + Snip("b", tags: ["read"]), + }; + + var result = SnipFilter.Apply(snips, "deploy", null).ToList(); + + Assert.Single(result); + Assert.Equal("a", result[0].Title); + } + + [Fact] + public void Selected_tag_restricts_to_snips_carrying_that_tag() + { + var snips = new[] + { + Snip("a", tags: ["deploy"]), + Snip("b", tags: ["read"]), + Snip("c", tags: ["DEPLOY"]), + }; + + var result = SnipFilter.Apply(snips, null, "deploy").ToList(); + + Assert.Equal(2, result.Count); + Assert.Contains(result, s => s.Title == "a"); + Assert.Contains(result, s => s.Title == "c"); + } + + [Fact] + public void Search_and_tag_apply_together_as_an_AND_filter() + { + var snips = new[] + { + Snip("List orgs", tags: ["read"]), + Snip("Deploy prod", tags: ["deploy"]), + Snip("Read logs", tags: ["read"]), + }; + + var result = SnipFilter.Apply(snips, "logs", "read").ToList(); + + Assert.Single(result); + Assert.Equal("Read logs", result[0].Title); + } + + [Fact] + public void Whitespace_only_search_is_ignored() + { + var snips = new[] { Snip("a"), Snip("b") }; + + var result = SnipFilter.Apply(snips, " ", null).ToList(); + + Assert.Equal(2, result.Count); + } + + [Fact] + public void DistinctTagsFor_returns_unique_tags_excluding_trash() + { + var snips = new[] + { + Snip("a", tags: ["deploy", "prod"]), + Snip("b", tags: ["prod", "read"]), + Snip("c", tags: ["secret"], isTrash: true), + }; + + var result = SnipFilter.DistinctTagsFor(snips).ToList(); + + Assert.Equal(3, result.Count); + Assert.Contains("deploy", result); + Assert.Contains("prod", result); + Assert.Contains("read", result); + Assert.DoesNotContain("secret", result); + } + } +} diff --git a/tests/Snipdeck.Core.Tests/ViewModels/HomeViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/HomeViewModelTests.cs new file mode 100644 index 0000000..05c51b6 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/ViewModels/HomeViewModelTests.cs @@ -0,0 +1,101 @@ +using Snipdeck.Core.Models; +using Snipdeck.Core.ViewModels; + +namespace Snipdeck.Core.Tests.ViewModels +{ + public class HomeViewModelTests + { + private static SnipStoreDocument Document(Action configure) + { + var doc = new SnipStoreDocument(); + configure(doc); + return doc; + } + + [Fact] + public void CliCards_are_sorted_alphabetically_and_carry_their_snip_counts() + { + var pl = new Cli { Name = "pl-app" }; + var inv = new Cli { Name = "inv-app" }; + var doc = Document(d => + { + d.Clis.Add(pl); + d.Clis.Add(inv); + d.Snips.Add(new Snip { CliId = pl.Id }); + d.Snips.Add(new Snip { CliId = pl.Id }); + d.Snips.Add(new Snip { CliId = inv.Id }); + }); + + var vm = new HomeViewModel(doc, searchText: null); + + Assert.Collection(vm.CliCards, + c => { Assert.Equal("inv-app", c.Name); Assert.Equal(1, c.SnipCount); }, + c => { Assert.Equal("pl-app", c.Name); Assert.Equal(2, c.SnipCount); }); + } + + [Fact] + public void Search_text_filters_cli_cards_by_name() + { + var doc = Document(d => + { + d.Clis.Add(new Cli { Name = "pl-app" }); + d.Clis.Add(new Cli { Name = "mpt-app" }); + }); + + var vm = new HomeViewModel(doc, searchText: "pl"); + + var single = Assert.Single(vm.CliCards); + Assert.Equal("pl-app", single.Name); + } + + [Fact] + public void MostUsedSnips_sorts_by_usage_count_then_last_used_desc() + { + var cli = new Cli { Name = "a" }; + var now = DateTimeOffset.UtcNow; + var doc = Document(d => + { + d.Clis.Add(cli); + d.Snips.Add(new Snip { CliId = cli.Id, Title = "x", UsageCount = 5, LastUsedAt = now.AddMinutes(-10) }); + d.Snips.Add(new Snip { CliId = cli.Id, Title = "y", UsageCount = 10, LastUsedAt = now.AddMinutes(-1) }); + d.Snips.Add(new Snip { CliId = cli.Id, Title = "z", UsageCount = 5, LastUsedAt = now }); + }); + + var vm = new HomeViewModel(doc, searchText: null); + + Assert.Collection(vm.MostUsedSnips, + s => Assert.Equal("y", s.Title), + s => Assert.Equal("z", s.Title), + s => Assert.Equal("x", s.Title)); + } + + [Fact] + public void MostUsedSnips_skips_unused_and_trashed_entries() + { + var cli = new Cli { Name = "a" }; + var doc = Document(d => + { + d.Clis.Add(cli); + d.Snips.Add(new Snip { CliId = cli.Id, Title = "never used", UsageCount = 0 }); + d.Snips.Add(new Snip { CliId = cli.Id, Title = "trashed", UsageCount = 99, IsTrash = true }); + d.Snips.Add(new Snip { CliId = cli.Id, Title = "kept", UsageCount = 3 }); + }); + + var vm = new HomeViewModel(doc, searchText: null); + + var single = Assert.Single(vm.MostUsedSnips); + Assert.Equal("kept", single.Title); + } + + [Fact] + public void Empty_document_produces_empty_collections_with_false_predicates() + { + var vm = new HomeViewModel(new SnipStoreDocument(), searchText: null); + + Assert.Empty(vm.CliCards); + Assert.Empty(vm.MostUsedSnips); + Assert.False(vm.HasCliCards); + Assert.False(vm.HasMostUsedSnips); + } + } +} diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs new file mode 100644 index 0000000..5b58d73 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs @@ -0,0 +1,145 @@ +using Snipdeck.Core.Abstractions; +using Snipdeck.Core.Models; +using Snipdeck.Core.ViewModels; + +namespace Snipdeck.Core.Tests.ViewModels +{ + public class ShellViewModelTests + { + private sealed class InMemorySnipStore(SnipStoreDocument document) : ISnipStore + { + private SnipStoreDocument _document = document; + + public string FilePath => "in-memory"; + + public Task LoadAsync(CancellationToken cancellationToken = default) => + Task.FromResult(_document); + + public Task SaveAsync(SnipStoreDocument document, CancellationToken cancellationToken = default) + { + _document = document; + return Task.CompletedTask; + } + } + + private static SnipStoreDocument SampleDocument(out Guid plAppId, out Guid mptAppId) + { + plAppId = Guid.NewGuid(); + mptAppId = Guid.NewGuid(); + var captured1 = plAppId; + var captured2 = mptAppId; + return new SnipStoreDocument + { + Clis = + { + new Cli { Id = captured1, Name = "pl-app" }, + new Cli { Id = captured2, Name = "mpt-app" }, + }, + Snips = + { + new Snip { Id = Guid.NewGuid(), CliId = captured1, Title = "List orgs", Tags = ["read", "orgs"] }, + new Snip { Id = Guid.NewGuid(), CliId = captured1, Title = "Deploy", Tags = ["deploy"] }, + new Snip { Id = Guid.NewGuid(), CliId = captured2, Title = "Users", Tags = ["users"] }, + }, + }; + } + + [Fact] + 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)); + + await vm.LoadAsync(); + + Assert.NotNull(vm.SelectedCliChoice); + Assert.True(vm.SelectedCliChoice!.IsHome); + _ = Assert.IsType(vm.CurrentContent); + } + + [Fact] + 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)); + + await vm.LoadAsync(); + + Assert.Equal(3, vm.CliChoices.Count); + Assert.True(vm.CliChoices[0].IsHome); + Assert.Equal("mpt-app", vm.CliChoices[1].Display); + Assert.Equal("pl-app", vm.CliChoices[2].Display); + } + + [Fact] + 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)); + await vm.LoadAsync(); + + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == plAppId); + + var cliVm = Assert.IsType(vm.CurrentContent); + Assert.Equal("pl-app", cliVm.Name); + Assert.Contains(ShellViewModel.AllTagsSentinel, vm.Tags); + Assert.Contains("read", vm.Tags); + Assert.Contains("deploy", vm.Tags); + Assert.Contains("orgs", vm.Tags); + Assert.Equal(ShellViewModel.AllTagsSentinel, vm.SelectedTag); + } + + [Fact] + 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)); + await vm.LoadAsync(); + + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == plAppId); + vm.GoHome(); + + Assert.Empty(vm.Tags); + _ = Assert.IsType(vm.CurrentContent); + } + + [Fact] + public async Task Changing_search_text_rebuilds_content() + { + var doc = SampleDocument(out _, out _); + var vm = new ShellViewModel(new InMemorySnipStore(doc)); + await vm.LoadAsync(); + + var first = vm.CurrentContent; + vm.SearchText = "deploy"; + + Assert.NotSame(first, vm.CurrentContent); + _ = Assert.IsType(vm.CurrentContent); + } + + [Fact] + public async Task OpenSettings_swaps_content_for_a_SettingsViewModel() + { + var doc = SampleDocument(out _, out _); + var vm = new ShellViewModel(new InMemorySnipStore(doc)); + await vm.LoadAsync(); + + vm.OpenSettings(); + + _ = Assert.IsType(vm.CurrentContent); + } + + [Fact] + 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)); + await vm.LoadAsync(); + + vm.OpenSettings(); + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == plAppId); + + _ = Assert.IsType(vm.CurrentContent); + } + } +} From de078053667afb90a47c070777a398b1d377cdd8 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 29 May 2026 17:08:05 +0000 Subject: [PATCH 2/2] Fix Phase 3 WinUI build: revert static SettingsViewModel members + partial on WinRT-interface types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x:Bind in a DataTemplate resolves bindings via the bound DataContext instance, so SettingsViewModel.AppName / TaglineDisplay can't be static — CS0176 in the generated code. Switch them back to instance properties with a narrow #pragma to silence CA1822 (they're cheap and only live while Settings is open). CsWinRT1028 wants types that implement WinRT interfaces (IValueConverter, DataTemplateSelector) marked partial so its source generator can emit the marshalling glue. Add 'partial' to BoolToVisibilityConverter, CountToVisibilityConverter, and ShellContentTemplateSelector. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Converters/BoolToVisibilityConverter.cs | 2 +- .../Converters/CountToVisibilityConverter.cs | 2 +- .../Views/ShellContentTemplateSelector.cs | 2 +- src/Snipdeck.Core/ViewModels/SettingsViewModel.cs | 11 ++++++++--- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Snipdeck.App/Converters/BoolToVisibilityConverter.cs b/src/Snipdeck.App/Converters/BoolToVisibilityConverter.cs index 1c7d54c..35a6b6a 100644 --- a/src/Snipdeck.App/Converters/BoolToVisibilityConverter.cs +++ b/src/Snipdeck.App/Converters/BoolToVisibilityConverter.cs @@ -3,7 +3,7 @@ namespace Snipdeck.App.Converters { - public sealed class BoolToVisibilityConverter : IValueConverter + public sealed partial class BoolToVisibilityConverter : IValueConverter { public bool Invert { get; set; } diff --git a/src/Snipdeck.App/Converters/CountToVisibilityConverter.cs b/src/Snipdeck.App/Converters/CountToVisibilityConverter.cs index 75d7f6e..28f6c74 100644 --- a/src/Snipdeck.App/Converters/CountToVisibilityConverter.cs +++ b/src/Snipdeck.App/Converters/CountToVisibilityConverter.cs @@ -7,7 +7,7 @@ namespace Snipdeck.App.Converters /// Visible when the bound integer is greater than zero. Used to hide /// sections such as "Most used" when the underlying collection is empty. /// - public sealed class CountToVisibilityConverter : IValueConverter + public sealed partial class CountToVisibilityConverter : IValueConverter { public bool Invert { get; set; } diff --git a/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs b/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs index 6d7cd9a..4a861b1 100644 --- a/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs +++ b/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs @@ -9,7 +9,7 @@ namespace Snipdeck.App.Views /// Picks the right for the shell's content area /// based on the view-model type held by ShellViewModel.CurrentContent. /// - public sealed class ShellContentTemplateSelector : DataTemplateSelector + public sealed partial class ShellContentTemplateSelector : DataTemplateSelector { public DataTemplate? HomeTemplate { get; set; } diff --git a/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs b/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs index d03a798..9ad2cf9 100644 --- a/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs @@ -21,12 +21,17 @@ public SettingsViewModel() ?? "Copyright © Stuart Meeks"; } - public static string AppName => "Snipdeck"; + // Instance properties (not static) so x:Bind in XAML resolves them via + // the bound DataContext. CA1822 doesn't matter here — these are cheap + // and only ever live for the duration of the open Settings view. +#pragma warning disable CA1822 + public string AppName => "Snipdeck"; + + public string TaglineDisplay => "Parameterised CLI snippet manager."; +#pragma warning restore CA1822 public string VersionDisplay { get; } public string CopyrightDisplay { get; } - - public static string TaglineDisplay => "Parameterised CLI snippet manager."; } }