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..35a6b6a --- /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 partial 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..28f6c74 --- /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 partial 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..4a861b1 --- /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 partial 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..9ad2cf9 --- /dev/null +++ b/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs @@ -0,0 +1,37 @@ +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"; + } + + // 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; } + } +} 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); + } + } +}