diff --git a/CHANGELOG.md b/CHANGELOG.md
index a3d0e06..952d441 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
+- **Home, navigation and shared-parameters polish.** The Home page leads with a
+ full-bleed hero banner (drop `Assets/HomeHero.png` to supply the image), the
+ CLI launcher is a horizontal carousel that overlaps the banner, and a centred
+ pill selector switches between Most used / Recent / Favourites. The navigation
+ pane toggle (hamburger) moved to the title bar (Home is the first nav item),
+ and the footer destinations (Shared parameters / Tags / Trash / Settings) now
+ show the selected indicator like the other nav items. Clicking Home also
+ clears the search. Shared parameters — global and per-CLI (reached from the CLI
+ view header) — now have a read-only card view with an edit modal, instead of
+ inline editing; the Tags and Shared-parameters panes are left-aligned. Settings
+ is grouped into "Appearance & behaviour" and "About"; the About expander
+ shows the version and copyright, with links to clone the repo and file issues.
+- **Shell layout: CLI switcher and search moved to the title bar.** The CLI
+ switcher and a snip search box now live in the custom title bar. Search is
+ snip-only with name autocomplete, scoped to the selected CLI; each suggestion
+ shows its CLI in a badge so identically-named snips are distinguishable, and
+ choosing one filters the list to it. The left navigation now leads with
+ **Home** and **Documentation** entries, followed by a **Tags** heading with an
+ **All** entry and the scoped tags; the footer actions (Shared parameters,
+ Tags, Trash, Settings) are left-aligned.
+- **Polished cards, dialogs and destructive actions.** Snip cards now size the
+ Copy button to its content and group Edit/Delete immediately beside it.
+ Destructive actions (Delete CLI, and the delete confirmations) use a subtle
+ red treatment. Dialogs have rounded corners, all dialog buttons share rounded
+ corners with more breathing room between them, and the snip editor is wider so
+ its content fills the available space. The snip editor's "Command template"
+ heading no longer inherits the monospace font.
- **JSON stores moved to System.Text.Json source generation.** `JsonSnipStore`
and `JsonSettingsStore` now serialise via a generated `JsonSerializerContext`
instead of the reflection-based serializer, removing the IL2026 trim warnings.
@@ -17,6 +44,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
IL2104 on the WinAppSDK/WinRT/Jdenticon assemblies, which aren't trim-safe.
### Added
+- **Redesigned Home page.** A gradient hero banner heads the page, the CLI
+ launcher uses landscape tiles (232×172) showing each CLI's description, and a
+ segmented selector below switches between **Most used**, **Recent** and
+ **Favourites** snips (drawn from every CLI) shown as a card grid.
+- **CLI descriptions.** A CLI can carry a short description, edited in the CLI
+ editor and shown on its Home card. (Store schema is now v4; an older build
+ refuses a v4 store rather than dropping descriptions.)
+- **Tag icons in the navigation.** Tags can carry a Segoe Fluent Icons glyph,
+ shown beside the tag in the left navigation (new tags default to a tag glyph). A new
+ "Tags" entry in the left-pane footer lets you set each tag's icon. Icons are
+ nav-only — snip tag chips are unchanged. (Store schema is now v3; an older
+ build refuses a v3 store rather than dropping tag icons.)
- **Shared parameter definitions.** Define a parameter once and reuse it
across snips, at two scopes: **CLI-scoped** (in the CLI editor — inherited by
every snip under that CLI) and **global** (a new "Shared parameters" entry in
diff --git a/src/Snipdeck.App/App.xaml b/src/Snipdeck.App/App.xaml
index 5797699..fc25777 100644
--- a/src/Snipdeck.App/App.xaml
+++ b/src/Snipdeck.App/App.xaml
@@ -3,14 +3,59 @@
x:Class="Snipdeck.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
- xmlns:local="using:Snipdeck.App">
+ xmlns:local="using:Snipdeck.App"
+ xmlns:views="using:Snipdeck.App.Views">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Snipdeck.App/Assets/HomeHeroDark.png b/src/Snipdeck.App/Assets/HomeHeroDark.png
new file mode 100644
index 0000000..8b95218
Binary files /dev/null and b/src/Snipdeck.App/Assets/HomeHeroDark.png differ
diff --git a/src/Snipdeck.App/Assets/HomeHeroLight.png b/src/Snipdeck.App/Assets/HomeHeroLight.png
new file mode 100644
index 0000000..725d4b9
Binary files /dev/null and b/src/Snipdeck.App/Assets/HomeHeroLight.png differ
diff --git a/src/Snipdeck.App/Assets/README.md b/src/Snipdeck.App/Assets/README.md
new file mode 100644
index 0000000..adf89aa
--- /dev/null
+++ b/src/Snipdeck.App/Assets/README.md
@@ -0,0 +1,16 @@
+# Assets
+
+## Home hero images (theme-specific)
+
+The Home page hero banner uses a theme-specific image:
+
+- `HomeHeroLight.png` — shown in the Light theme
+- `HomeHeroDark.png` — shown in the Dark (and High Contrast) theme
+
+Drop both here (≈2400×1000, wide) and the header swaps them with the selected
+theme. Keep the **upper-left area light/clear** in the light image (and suitably
+contrasted in the dark image) — the title text uses the theme foreground colour
+and sits top-left. The image's lower edge fades into the header background.
+
+Until the files are present, the header shows its background colour. See the
+Firefly prompt in the project history for generating them.
diff --git a/src/Snipdeck.App/Bootstrap.cs b/src/Snipdeck.App/Bootstrap.cs
index d2e976b..ac1f450 100644
--- a/src/Snipdeck.App/Bootstrap.cs
+++ b/src/Snipdeck.App/Bootstrap.cs
@@ -44,6 +44,7 @@ public static IServiceProvider Build()
.AddSingleton()
.AddSingleton()
.AddSingleton()
+ .AddSingleton()
.AddSingleton(new StorageRelocationService(_snipStoreFileName))
.AddSingleton()
.AddSingleton()
diff --git a/src/Snipdeck.App/Controls/CliCard.xaml b/src/Snipdeck.App/Controls/CliCard.xaml
index 68c3576..3aa69e1 100644
--- a/src/Snipdeck.App/Controls/CliCard.xaml
+++ b/src/Snipdeck.App/Controls/CliCard.xaml
@@ -4,48 +4,68 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Snipdeck.App.Controls"
+ xmlns:converters="using:Snipdeck.App.Converters"
xmlns:vm="using:Snipdeck.Core.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
-
-
-
-
-
-
+
+
+
-
+
+
+
diff --git a/src/Snipdeck.App/Controls/CliCard.xaml.cs b/src/Snipdeck.App/Controls/CliCard.xaml.cs
index 1784130..0c42fc4 100644
--- a/src/Snipdeck.App/Controls/CliCard.xaml.cs
+++ b/src/Snipdeck.App/Controls/CliCard.xaml.cs
@@ -2,7 +2,6 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
-using Microsoft.UI.Xaml.Input;
using Snipdeck.Core.ViewModels;
@@ -34,13 +33,5 @@ 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 328ae4a..47e9015 100644
--- a/src/Snipdeck.App/Controls/SnipCard.xaml
+++ b/src/Snipdeck.App/Controls/SnipCard.xaml
@@ -61,7 +61,9 @@
Text="{x:Bind ViewModel.CommandTemplate, Mode=OneWay}"
FontFamily="Cascadia Mono, Consolas, Courier New"
FontSize="12"
- TextWrapping="Wrap" />
+ TextWrapping="Wrap"
+ MaxLines="3"
+ TextTrimming="CharacterEllipsis" />
-
+
+
+ CommandParameter="{x:Bind ViewModel, Mode=OneWay}" />
-
+
+
+ HorizontalAlignment="Left"
+ VerticalAlignment="Center"
+ Spacing="4">
+
+
+ VerticalAlignment="Center"
+ Margin="4,0,0,0" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
UpdateTitleBarPassthrough();
ShellHost.Content = shellPage;
ApplyTheme(config.Theme);
}
+ // The title-bar switcher and snip search bind to the shell view model.
+ public ShellViewModel Shell { get; }
+
+ // The title-bar hamburger toggles the shell's navigation pane.
+ private void OnPaneToggleClicked(object sender, RoutedEventArgs e)
+ {
+ _shellPage.TogglePane();
+ }
+
+ // Keep the title-bar search box in sync when the view model clears the
+ // search (e.g. clicking Home), since the box text is otherwise UI-only.
+ private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(ShellViewModel.SearchText)
+ && string.IsNullOrEmpty(Shell.SearchText)
+ && !string.IsNullOrEmpty(SearchBox.Text))
+ {
+ SearchBox.Text = string.Empty;
+ }
+ }
+
+ private void OnTitleBarControlsChanged(object sender, SizeChangedEventArgs e)
+ {
+ UpdateTitleBarPassthrough();
+ }
+
+ // Mark the interactive title-bar elements (hamburger + the centred
+ // search/switcher group) as passthrough regions so they receive pointer
+ // input instead of the title-bar drag handler.
+ private void UpdateTitleBarPassthrough()
+ {
+ if (TitleBarControls.XamlRoot is null)
+ {
+ return;
+ }
+
+ var scale = TitleBarControls.XamlRoot.RasterizationScale;
+ var rects = new List();
+ foreach (var element in new FrameworkElement[] { PaneToggleButton, TitleBarControls })
+ {
+ if (element.ActualWidth <= 0 || element.ActualHeight <= 0)
+ {
+ continue;
+ }
+ var bounds = element
+ .TransformToVisual(Content)
+ .TransformBounds(new Windows.Foundation.Rect(0, 0, element.ActualWidth, element.ActualHeight));
+ rects.Add(new Windows.Graphics.RectInt32(
+ (int)Math.Round(bounds.X * scale),
+ (int)Math.Round(bounds.Y * scale),
+ (int)Math.Round(bounds.Width * scale),
+ (int)Math.Round(bounds.Height * scale)));
+ }
+
+ InputNonClientPointerSource
+ .GetForWindowId(AppWindow.Id)
+ .SetRegionRects(NonClientRegionKind.Passthrough, [.. rects]);
+ }
+
+ private void OnSearchTextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
+ {
+ if (args.Reason == AutoSuggestionBoxTextChangeReason.UserInput)
+ {
+ if (string.IsNullOrEmpty(sender.Text))
+ {
+ // Clearing the box clears any active filter (without leaving Home).
+ Shell.SearchText = string.Empty;
+ }
+ sender.ItemsSource = Shell.GetSearchSuggestions(sender.Text);
+ }
+ }
+
+ private void OnSearchSuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args)
+ {
+ if (args.SelectedItem is SnipSearchResult result)
+ {
+ Shell.SelectSearchResult(result);
+ }
+ }
+
+ private void OnSearchQuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args)
+ {
+ if (args.ChosenSuggestion is SnipSearchResult result)
+ {
+ Shell.SelectSearchResult(result);
+ }
+ else
+ {
+ Shell.ApplySearch(args.QueryText);
+ }
+ }
+
private void ApplyTheme(ThemePreference theme)
{
if (Content is FrameworkElement root)
diff --git a/src/Snipdeck.App/Services/WindowsExternalLinkService.cs b/src/Snipdeck.App/Services/WindowsExternalLinkService.cs
new file mode 100644
index 0000000..4c18857
--- /dev/null
+++ b/src/Snipdeck.App/Services/WindowsExternalLinkService.cs
@@ -0,0 +1,15 @@
+using Snipdeck.Core.Abstractions;
+
+namespace Snipdeck.App.Services
+{
+ internal sealed class WindowsExternalLinkService : IExternalLinkService
+ {
+ public async Task OpenAsync(string url)
+ {
+ if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
+ {
+ _ = await Windows.System.Launcher.LaunchUriAsync(uri);
+ }
+ }
+ }
+}
diff --git a/src/Snipdeck.App/Services/WindowsShellInteractions.cs b/src/Snipdeck.App/Services/WindowsShellInteractions.cs
index 4f9c8aa..e669c2c 100644
--- a/src/Snipdeck.App/Services/WindowsShellInteractions.cs
+++ b/src/Snipdeck.App/Services/WindowsShellInteractions.cs
@@ -32,7 +32,7 @@ public WindowsShellInteractions(
_filePicker = filePicker;
}
- public async Task ConfirmAsync(string title, string message, string confirmButtonText = "Yes", string cancelButtonText = "Cancel")
+ public async Task ConfirmAsync(string title, string message, string confirmButtonText = "Yes", string cancelButtonText = "Cancel", bool destructive = false)
{
var dialog = new ContentDialog
{
@@ -40,9 +40,17 @@ public async Task ConfirmAsync(string title, string message, string confir
Content = message,
PrimaryButtonText = confirmButtonText,
CloseButtonText = cancelButtonText,
- DefaultButton = ContentDialogButton.Primary,
+ // For destructive confirms, make Cancel the default: it's the safe
+ // choice, and it stops the default-button accent treatment from
+ // overriding the primary button's subtle-red foreground.
+ DefaultButton = destructive ? ContentDialogButton.Close : ContentDialogButton.Primary,
XamlRoot = GetXamlRoot(),
+ RequestedTheme = CurrentTheme(),
};
+ if (destructive && Application.Current.Resources["DangerDialogPrimaryButtonStyle"] is Style dangerStyle)
+ {
+ dialog.PrimaryButtonStyle = dangerStyle;
+ }
var result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary;
}
@@ -56,6 +64,7 @@ public async Task NotifyAsync(string title, string message, string buttonText =
CloseButtonText = buttonText,
DefaultButton = ContentDialogButton.Close,
XamlRoot = GetXamlRoot(),
+ RequestedTheme = CurrentTheme(),
};
_ = await dialog.ShowAsync();
}
@@ -67,6 +76,7 @@ public async Task NotifyAsync(string title, string message, string buttonText =
var dialog = new SnipEditorDialog(editor)
{
XamlRoot = GetXamlRoot(),
+ RequestedTheme = CurrentTheme(),
};
var result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary
@@ -81,6 +91,7 @@ public async Task NotifyAsync(string title, string message, string buttonText =
var dialog = new CliEditorDialog(editor, _iconNormaliser, _filePicker)
{
XamlRoot = GetXamlRoot(),
+ RequestedTheme = CurrentTheme(),
};
var result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary
@@ -88,6 +99,18 @@ public async Task NotifyAsync(string title, string message, string buttonText =
: null;
}
+ public async Task EditParameterAsync(string title, Parameter? existing)
+ {
+ var row = new ParameterEditorRowViewModel(existing ?? new Parameter { Name = "param" });
+ var dialog = new ParameterEditorDialog(title, row)
+ {
+ XamlRoot = GetXamlRoot(),
+ RequestedTheme = CurrentTheme(),
+ };
+ var result = await dialog.ShowAsync();
+ return result == ContentDialogResult.Primary ? row.BuildParameter() : null;
+ }
+
public async Task FillParametersAsync(Snip snip, IReadOnlyList parameters)
{
ArgumentNullException.ThrowIfNull(snip);
@@ -96,6 +119,7 @@ public async Task NotifyAsync(string title, string message, string buttonText =
var dialog = new ParameterFillDialog(fill)
{
XamlRoot = GetXamlRoot(),
+ RequestedTheme = CurrentTheme(),
};
var result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary && fill.IsCopyEnabled
@@ -111,5 +135,15 @@ private XamlRoot GetXamlRoot()
return ((FrameworkElement)content).XamlRoot;
}
+ // Dialogs are separate visual roots, so they don't inherit the in-app theme
+ // (applied via RequestedTheme on the main window content). Mirror it so a
+ // dialog opened after a Light/Dark switch matches, instead of the OS theme.
+ private ElementTheme CurrentTheme()
+ {
+ var mainWindow = (MainWindow)_services.GetService(typeof(MainWindow))!;
+ return mainWindow.Content is FrameworkElement content
+ ? content.RequestedTheme
+ : ElementTheme.Default;
+ }
}
}
diff --git a/src/Snipdeck.App/Snipdeck.App.csproj b/src/Snipdeck.App/Snipdeck.App.csproj
index dbfa9a4..9e2c95c 100644
--- a/src/Snipdeck.App/Snipdeck.App.csproj
+++ b/src/Snipdeck.App/Snipdeck.App.csproj
@@ -28,6 +28,14 @@
+
+
+
+
+
+
+
+
diff --git a/src/Snipdeck.App/Views/CliEditorDialog.xaml b/src/Snipdeck.App/Views/CliEditorDialog.xaml
index 051280b..2a5dc66 100644
--- a/src/Snipdeck.App/Views/CliEditorDialog.xaml
+++ b/src/Snipdeck.App/Views/CliEditorDialog.xaml
@@ -12,11 +12,19 @@
CloseButtonText="Cancel"
DefaultButton="Primary">
-
+
+
+
+
@@ -39,69 +47,8 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Text
- Choice
-
-
-
-
-
-
-
-
+
diff --git a/src/Snipdeck.App/Views/CliEditorDialog.xaml.cs b/src/Snipdeck.App/Views/CliEditorDialog.xaml.cs
index 278d57f..e3da04d 100644
--- a/src/Snipdeck.App/Views/CliEditorDialog.xaml.cs
+++ b/src/Snipdeck.App/Views/CliEditorDialog.xaml.cs
@@ -34,19 +34,6 @@ 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);
- }
- }
-
private async void OnPickIconClicked(object sender, RoutedEventArgs e)
{
var picked = await _filePicker.PickImageAsync();
diff --git a/src/Snipdeck.App/Views/ParameterEditorDialog.xaml b/src/Snipdeck.App/Views/ParameterEditorDialog.xaml
new file mode 100644
index 0000000..6d48bea
--- /dev/null
+++ b/src/Snipdeck.App/Views/ParameterEditorDialog.xaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ Text
+ Choice
+
+
+
+
+
diff --git a/src/Snipdeck.App/Views/ParameterEditorDialog.xaml.cs b/src/Snipdeck.App/Views/ParameterEditorDialog.xaml.cs
new file mode 100644
index 0000000..d98ba49
--- /dev/null
+++ b/src/Snipdeck.App/Views/ParameterEditorDialog.xaml.cs
@@ -0,0 +1,19 @@
+using Microsoft.UI.Xaml.Controls;
+
+using Snipdeck.Core.ViewModels;
+
+namespace Snipdeck.App.Views
+{
+ public sealed partial class ParameterEditorDialog : ContentDialog
+ {
+ public ParameterEditorDialog(string title, ParameterEditorRowViewModel row)
+ {
+ ArgumentNullException.ThrowIfNull(row);
+ Row = row;
+ InitializeComponent();
+ Title = title;
+ }
+
+ public ParameterEditorRowViewModel Row { get; }
+ }
+}
diff --git a/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs b/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs
index ec2a33c..ec92f3f 100644
--- a/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs
+++ b/src/Snipdeck.App/Views/ShellContentTemplateSelector.cs
@@ -19,7 +19,9 @@ public sealed partial class ShellContentTemplateSelector : DataTemplateSelector
public DataTemplate? TrashTemplate { get; set; }
- public DataTemplate? GlobalParametersTemplate { get; set; }
+ public DataTemplate? SharedParametersTemplate { get; set; }
+
+ public DataTemplate? TagIconsTemplate { get; set; }
protected override DataTemplate? SelectTemplateCore(object item)
{
@@ -29,7 +31,8 @@ public sealed partial class ShellContentTemplateSelector : DataTemplateSelector
CliViewModel => CliTemplate,
SettingsViewModel => SettingsTemplate,
TrashViewModel => TrashTemplate,
- GlobalParametersViewModel => GlobalParametersTemplate,
+ SharedParametersViewModel => SharedParametersTemplate,
+ TagIconsViewModel => TagIconsTemplate,
_ => null,
};
}
diff --git a/src/Snipdeck.App/Views/ShellPage.xaml b/src/Snipdeck.App/Views/ShellPage.xaml
index 9400993..d665b3c 100644
--- a/src/Snipdeck.App/Views/ShellPage.xaml
+++ b/src/Snipdeck.App/Views/ShellPage.xaml
@@ -14,74 +14,180 @@
x:Name="ShellRoot">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
@@ -99,23 +205,35 @@
+
+
+
-
-
+ Style="{ThemeResource AccentButtonStyle}"
+ Visibility="{x:Bind HasCli, Converter={StaticResource BoolToVisibility}}"
+ Click="OnNewSnipClicked" />
+
+
@@ -210,6 +332,10 @@
MinWidth="120" />
+
+
@@ -225,21 +351,28 @@
-
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
@@ -249,7 +382,7 @@
-
+
-
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Command="{Binding ElementName=ShellRoot, Path=ViewModel.SaveTagIconsCommand}" />
-
+ Visibility="{x:Bind IsEmpty, Converter={StaticResource BoolToVisibility}}" />
-
+
-
+
-
-
-
-
-
-
-
-
-
-
- Text
- Choice
-
-
-
-
+
+
+
+
+
+
+
+
+
+
@@ -407,68 +628,59 @@
CliTemplate="{StaticResource CliContentTemplate}"
SettingsTemplate="{StaticResource SettingsContentTemplate}"
TrashTemplate="{StaticResource TrashContentTemplate}"
- GlobalParametersTemplate="{StaticResource GlobalParametersContentTemplate}" />
+ SharedParametersTemplate="{StaticResource SharedParametersContentTemplate}"
+ TagIconsTemplate="{StaticResource TagIconsContentTemplate}" />
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ ItemInvoked="OnNavigationItemInvoked">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
_tagItems = [];
+
public ShellPage(ShellViewModel viewModel)
{
ArgumentNullException.ThrowIfNull(viewModel);
ViewModel = viewModel;
InitializeComponent();
+
+ ViewModel.Tags.CollectionChanged += OnTagsChanged;
+ ViewModel.PropertyChanged += OnViewModelPropertyChanged;
+
Loaded += OnLoaded;
}
public ShellViewModel ViewModel { get; }
+ private static FontFamily SymbolFont =>
+ Application.Current.Resources.TryGetValue("SymbolThemeFontFamily", out var resource)
+ && resource is FontFamily family
+ ? family
+ : new FontFamily("Segoe Fluent Icons");
+
private async void OnLoaded(object sender, RoutedEventArgs e)
{
await ViewModel.LoadAsync();
}
- private void OnSettingsClicked(object sender, RoutedEventArgs e)
+ private void OnTagsChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ RebuildTagNavItems();
+ }
+
+ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName is nameof(ShellViewModel.SelectedTagItem) or nameof(ShellViewModel.CurrentContent))
+ {
+ SyncSelectionFromViewModel();
+ }
+ }
+
+ private void RebuildTagNavItems()
+ {
+ foreach (var item in _tagItems)
+ {
+ _ = ShellNavigation.MenuItems.Remove(item);
+ }
+ _tagItems.Clear();
+
+ foreach (var tag in ViewModel.Tags)
+ {
+ var navItem = new NavigationViewItem
+ {
+ Content = tag.Name,
+ Tag = tag,
+ Icon = new FontIcon
+ {
+ FontFamily = SymbolFont,
+ Glyph = tag.Glyph,
+ },
+ };
+ ShellNavigation.MenuItems.Add(navItem);
+ _tagItems.Add(navItem);
+ }
+
+ SyncSelectionFromViewModel();
+ }
+
+ /// Toggle the navigation pane (driven by the title-bar hamburger).
+ public void TogglePane()
+ {
+ ShellNavigation.IsPaneOpen = !ShellNavigation.IsPaneOpen;
+ }
+
+ // Reflect the view model's current content onto the NavigationView selection
+ // so the right item (tag, Home, or a footer destination) shows the selected look.
+ private void SyncSelectionFromViewModel()
+ {
+ ShellNavigation.SelectedItem = ViewModel.CurrentContent switch
+ {
+ HomeViewModel => HomeNavItem,
+ SettingsViewModel => SettingsNavItem,
+ TrashViewModel => TrashNavItem,
+ // Only the global set maps to the footer item; a CLI-scoped set is
+ // reached from the CLI view, so it leaves the footer unselected.
+ SharedParametersViewModel { IsGlobal: true } => SharedParametersNavItem,
+ TagIconsViewModel => TagsNavItem,
+ CliViewModel => _tagItems.FirstOrDefault(i => ReferenceEquals(i.Tag, ViewModel.SelectedTagItem)),
+ _ => null,
+ };
+ }
+
+ private async void OnNavigationItemInvoked(NavigationView sender, NavigationViewItemInvokedEventArgs args)
+ {
+ if (args.InvokedItemContainer is not NavigationViewItem item)
+ {
+ return;
+ }
+
+ if (ReferenceEquals(item, HomeNavItem))
+ {
+ ViewModel.ShowHome();
+ }
+ else if (ReferenceEquals(item, DocumentationNavItem))
+ {
+ await ViewModel.OpenDocumentationAsync();
+ }
+ else if (ReferenceEquals(item, SharedParametersNavItem))
+ {
+ ViewModel.OpenGlobalParameters();
+ }
+ else if (ReferenceEquals(item, TagsNavItem))
+ {
+ ViewModel.OpenTagIcons();
+ }
+ else if (ReferenceEquals(item, TrashNavItem))
+ {
+ ViewModel.OpenTrash();
+ }
+ else if (ReferenceEquals(item, SettingsNavItem))
+ {
+ ViewModel.OpenSettings(App.Services.GetRequiredService());
+ }
+ else if (item.Tag is TagItemViewModel tag)
+ {
+ ViewModel.SelectTag(tag);
+ }
+ }
+
+ // Keep the active category toggle checked even when it's re-clicked (the
+ // category command is a no-op then, so the OneWay binding wouldn't re-assert).
+ private void OnHomeCategoryToggled(object sender, RoutedEventArgs e)
{
- var settings = App.Services.GetRequiredService();
- ViewModel.OpenSettings(settings);
+ ((ToggleButton)sender).IsChecked = true;
}
- private void OnTrashClicked(object sender, RoutedEventArgs e)
+ // The hero image is painted via a Composition mask brush so its lower edge
+ // fades to transparent — revealing the page's Mica — with no solid colour
+ // (which previously created a hard line / shade mismatch).
+ private void OnHeroHostLoaded(object sender, RoutedEventArgs e)
{
- ViewModel.OpenTrash();
+ var host = (Border)sender;
+ host.ActualThemeChanged -= OnHeroHostThemeChanged;
+ host.ActualThemeChanged += OnHeroHostThemeChanged;
+ ApplyHeroVisual(host);
}
- private void OnSharedParametersClicked(object sender, RoutedEventArgs e)
+ private void OnHeroHostSizeChanged(object sender, SizeChangedEventArgs e)
{
- ViewModel.OpenGlobalParameters();
+ var host = (Border)sender;
+ if (ElementCompositionPreview.GetElementChildVisual(host) is SpriteVisual visual)
+ {
+ visual.Size = new Vector2((float)host.ActualWidth, (float)host.ActualHeight);
+ }
+ else
+ {
+ ApplyHeroVisual(host);
+ }
}
- private void OnNavigationSelectionChanged(
- NavigationView sender,
- NavigationViewSelectionChangedEventArgs args)
+ private void OnHeroHostThemeChanged(FrameworkElement sender, object args)
{
- if (args.SelectedItem is string tag)
+ ApplyHeroVisual((Border)sender);
+ }
+
+ private static void ApplyHeroVisual(Border host)
+ {
+ if (host.ActualWidth <= 0 || host.ActualHeight <= 0)
{
- ViewModel.SelectedTag = tag;
+ return;
}
+
+ var compositor = ElementCompositionPreview.GetElementVisual(host).Compositor;
+
+ var uri = new Uri(host.ActualTheme == ElementTheme.Dark
+ ? "ms-appx:///Assets/HomeHeroDark.png"
+ : "ms-appx:///Assets/HomeHeroLight.png");
+ var surfaceBrush = compositor.CreateSurfaceBrush(LoadedImageSurface.StartLoadFromUri(uri));
+ surfaceBrush.Stretch = CompositionStretch.UniformToFill;
+
+ // Mask alpha: white = visible, transparent = hidden. The image fades out
+ // toward the bottom, revealing the page background behind it.
+ var gradient = compositor.CreateLinearGradientBrush();
+ gradient.StartPoint = new Vector2(0f, 0f);
+ gradient.EndPoint = new Vector2(0f, 1f);
+ gradient.ColorStops.Add(compositor.CreateColorGradientStop(0.0f, Colors.White));
+ gradient.ColorStops.Add(compositor.CreateColorGradientStop(0.6f, Colors.White));
+ gradient.ColorStops.Add(compositor.CreateColorGradientStop(1.0f, Color.FromArgb(0, 255, 255, 255)));
+
+ var mask = compositor.CreateMaskBrush();
+ mask.Source = surfaceBrush;
+ mask.Mask = gradient;
+
+ var visual = compositor.CreateSpriteVisual();
+ visual.Brush = mask;
+ visual.Size = new Vector2((float)host.ActualWidth, (float)host.ActualHeight);
+
+ ElementCompositionPreview.SetElementChildVisual(host, visual);
+ }
+
+ private async void OnCopyCloneCommandClicked(object sender, RoutedEventArgs e)
+ {
+ var clipboard = App.Services.GetRequiredService();
+ await clipboard.SetTextAsync("git clone https://github.com/StuartMeeks/Snipdeck");
}
private void OnNewCliClicked(object sender, RoutedEventArgs e)
diff --git a/src/Snipdeck.App/Views/SnipEditorDialog.xaml b/src/Snipdeck.App/Views/SnipEditorDialog.xaml
index 35d647d..06365f1 100644
--- a/src/Snipdeck.App/Views/SnipEditorDialog.xaml
+++ b/src/Snipdeck.App/Views/SnipEditorDialog.xaml
@@ -13,16 +13,30 @@
CloseButtonText="Cancel"
DefaultButton="Primary">
-
+
+
+ 860
+
+
+
+
-
+ TextWrapping="Wrap">
+
+
+
+
+
Opens an external URL (e.g. documentation) in the default browser.
+ public interface IExternalLinkService
+ {
+ Task OpenAsync(string url);
+ }
+}
diff --git a/src/Snipdeck.Core/Abstractions/IShellInteractions.cs b/src/Snipdeck.Core/Abstractions/IShellInteractions.cs
index fa9319b..ed985e0 100644
--- a/src/Snipdeck.Core/Abstractions/IShellInteractions.cs
+++ b/src/Snipdeck.Core/Abstractions/IShellInteractions.cs
@@ -13,7 +13,8 @@ Task ConfirmAsync(
string title,
string message,
string confirmButtonText = "Yes",
- string cancelButtonText = "Cancel");
+ string cancelButtonText = "Cancel",
+ bool destructive = false);
Task NotifyAsync(
string title,
@@ -24,6 +25,13 @@ Task NotifyAsync(
Task EditCliAsync(Cli cli);
+ ///
+ /// Opens the single-parameter edit modal. Pass to
+ /// edit it, or null to add a new one. Returns the edited parameter, or
+ /// null if the user cancelled.
+ ///
+ Task EditParameterAsync(string title, Parameter? existing);
+
Task FillParametersAsync(Snip snip, IReadOnlyList parameters);
}
diff --git a/src/Snipdeck.Core/Models/Cli.cs b/src/Snipdeck.Core/Models/Cli.cs
index 213cbf2..6c02f24 100644
--- a/src/Snipdeck.Core/Models/Cli.cs
+++ b/src/Snipdeck.Core/Models/Cli.cs
@@ -6,6 +6,9 @@ public sealed class Cli
public string Name { get; set; } = string.Empty;
+ /// Short, free-text summary shown on the CLI's Home card.
+ public string Description { get; set; } = string.Empty;
+
public string? IconRef { get; set; }
///
diff --git a/src/Snipdeck.Core/Models/SnipStoreDocument.cs b/src/Snipdeck.Core/Models/SnipStoreDocument.cs
index 835a1ac..3e0b989 100644
--- a/src/Snipdeck.Core/Models/SnipStoreDocument.cs
+++ b/src/Snipdeck.Core/Models/SnipStoreDocument.cs
@@ -3,9 +3,11 @@ namespace Snipdeck.Core.Models
public sealed class SnipStoreDocument
{
// v2 adds shared parameter definitions (Cli.Parameters + GlobalParameters).
- // Additive and forward-incompatible: a v1-only build refuses a v2 store
- // rather than silently dropping shared parameters.
- public const int CurrentSchemaVersion = 2;
+ // v3 adds per-tag icon glyphs (Cli.TagIcons).
+ // v4 adds Cli.Description.
+ // Additive and forward-incompatible: an older build refuses a newer store
+ // rather than silently dropping the new fields.
+ public const int CurrentSchemaVersion = 4;
public int SchemaVersion { get; set; } = CurrentSchemaVersion;
@@ -20,5 +22,12 @@ public sealed class SnipStoreDocument
/// precedence). See ParameterResolver.
///
public List GlobalParameters { get; set; } = [];
+
+ ///
+ /// Icon glyph per tag name (Segoe Fluent Icons), applied wherever that
+ /// tag appears in the left navigation. Tag names absent from the map use
+ /// the default "#" glyph. Tags are matched by name across all CLIs.
+ ///
+ public Dictionary TagIcons { get; set; } = [];
}
}
diff --git a/src/Snipdeck.Core/Services/ExamplesSeed.cs b/src/Snipdeck.Core/Services/ExamplesSeed.cs
index 33ecc7b..9aa3fcf 100644
--- a/src/Snipdeck.Core/Services/ExamplesSeed.cs
+++ b/src/Snipdeck.Core/Services/ExamplesSeed.cs
@@ -15,7 +15,11 @@ public static bool IsEmpty(SnipStoreDocument document)
public static SnipStoreDocument Build()
{
- var cli = new Cli { Name = CliName };
+ var cli = new Cli
+ {
+ Name = CliName,
+ Description = "A starter CLI with a few representative snips. Delete it once you're oriented.",
+ };
var document = new SnipStoreDocument();
document.Clis.Add(cli);
diff --git a/src/Snipdeck.Core/ViewModels/CliCardViewModel.cs b/src/Snipdeck.Core/ViewModels/CliCardViewModel.cs
index aa6df16..515f017 100644
--- a/src/Snipdeck.Core/ViewModels/CliCardViewModel.cs
+++ b/src/Snipdeck.Core/ViewModels/CliCardViewModel.cs
@@ -19,6 +19,10 @@ public CliCardViewModel(Cli cli, int snipCount)
public string Name => Cli.Name;
+ public string Description => Cli.Description;
+
+ public bool HasDescription => !string.IsNullOrWhiteSpace(Cli.Description);
+
public string? IconRef => Cli.IconRef;
public int SnipCount { get; }
diff --git a/src/Snipdeck.Core/ViewModels/CliChoice.cs b/src/Snipdeck.Core/ViewModels/CliChoice.cs
index 4e20741..ca48aaa 100644
--- a/src/Snipdeck.Core/ViewModels/CliChoice.cs
+++ b/src/Snipdeck.Core/ViewModels/CliChoice.cs
@@ -3,9 +3,9 @@
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 .
+ /// One entry in the CLI switcher (title bar). is true for
+ /// the synthetic "All" entry at the top — the unscoped view across every CLI;
+ /// for every other entry points at a real .
///
public sealed class CliChoice
{
@@ -13,7 +13,7 @@ public sealed class CliChoice
public string Display { get; init; } = string.Empty;
- public bool IsHome => Cli is null;
+ public bool IsAll => Cli is null;
public override string ToString()
{
diff --git a/src/Snipdeck.Core/ViewModels/CliEditorViewModel.cs b/src/Snipdeck.Core/ViewModels/CliEditorViewModel.cs
index 8689b6b..deaf07d 100644
--- a/src/Snipdeck.Core/ViewModels/CliEditorViewModel.cs
+++ b/src/Snipdeck.Core/ViewModels/CliEditorViewModel.cs
@@ -14,6 +14,7 @@ public CliEditorViewModel(Cli cli)
Cli = cli;
Name = cli.Name;
+ Description = cli.Description;
Parameters = new ObservableCollection(
cli.Parameters.Select(p => new ParameterEditorRowViewModel(p)));
}
@@ -23,6 +24,9 @@ public CliEditorViewModel(Cli cli)
[ObservableProperty]
public partial string Name { get; set; } = string.Empty;
+ [ObservableProperty]
+ public partial string Description { get; set; } = string.Empty;
+
[ObservableProperty]
public partial byte[]? PickedIconBytes { get; set; }
@@ -50,6 +54,7 @@ public Cli BuildUpdatedCli()
{
Id = Cli.Id,
Name = Name.Trim(),
+ Description = Description.Trim(),
IconRef = Cli.IconRef,
Parameters = [.. Parameters.Select(r => r.BuildParameter())],
};
diff --git a/src/Snipdeck.Core/ViewModels/CliViewModel.cs b/src/Snipdeck.Core/ViewModels/CliViewModel.cs
index d242818..07f0a0e 100644
--- a/src/Snipdeck.Core/ViewModels/CliViewModel.cs
+++ b/src/Snipdeck.Core/ViewModels/CliViewModel.cs
@@ -8,9 +8,8 @@ namespace Snipdeck.Core.ViewModels
{
public sealed partial class CliViewModel : ObservableObject
{
- public CliViewModel(Cli cli, IEnumerable filteredSnips)
+ public CliViewModel(Cli? cli, IEnumerable filteredSnips)
{
- ArgumentNullException.ThrowIfNull(cli);
ArgumentNullException.ThrowIfNull(filteredSnips);
Cli = cli;
@@ -21,9 +20,13 @@ public CliViewModel(Cli cli, IEnumerable filteredSnips)
.Select(s => new SnipCardViewModel(s)));
}
- public Cli Cli { get; }
+ /// The CLI in scope, or null for the unscoped "All" view across every CLI.
+ public Cli? Cli { get; }
- public string Name => Cli.Name;
+ /// True for a single CLI — gates the CLI-specific header actions (edit/delete/new).
+ public bool HasCli => Cli is not null;
+
+ public string Name => Cli?.Name ?? "All snips";
public ObservableCollection Snips { get; }
diff --git a/src/Snipdeck.Core/ViewModels/GlobalParametersViewModel.cs b/src/Snipdeck.Core/ViewModels/GlobalParametersViewModel.cs
deleted file mode 100644
index 5d1e979..0000000
--- a/src/Snipdeck.Core/ViewModels/GlobalParametersViewModel.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-using System.Collections.ObjectModel;
-
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-
-using Snipdeck.Core.Models;
-
-namespace Snipdeck.Core.ViewModels
-{
- ///
- /// The "Shared parameters" content view: edits the global (cross-CLI)
- /// parameter definitions. Add/remove rows here; the shell persists them via
- /// its SaveGlobalParameters command.
- ///
- public sealed partial class GlobalParametersViewModel : ObservableObject
- {
- public GlobalParametersViewModel(IReadOnlyList parameters)
- {
- ArgumentNullException.ThrowIfNull(parameters);
- Parameters = new ObservableCollection(
- parameters.Select(p => new ParameterEditorRowViewModel(p)));
- }
-
- public ObservableCollection Parameters { get; }
-
- [ObservableProperty]
- public partial string StatusMessage { get; set; } = string.Empty;
-
- [RelayCommand]
- private void AddParameter()
- {
- Parameters.Add(new ParameterEditorRowViewModel(new Parameter { Name = "param" }));
- StatusMessage = string.Empty;
- }
-
- [RelayCommand]
- private void RemoveParameter(ParameterEditorRowViewModel? row)
- {
- if (row is not null)
- {
- _ = Parameters.Remove(row);
- StatusMessage = string.Empty;
- }
- }
-
- public List BuildParameters() => [.. Parameters.Select(r => r.BuildParameter())];
- }
-}
diff --git a/src/Snipdeck.Core/ViewModels/GlyphInput.cs b/src/Snipdeck.Core/ViewModels/GlyphInput.cs
new file mode 100644
index 0000000..0c98d43
--- /dev/null
+++ b/src/Snipdeck.Core/ViewModels/GlyphInput.cs
@@ -0,0 +1,51 @@
+using System.Globalization;
+
+namespace Snipdeck.Core.ViewModels
+{
+ ///
+ /// Normalises a user-entered icon glyph into the actual character to render.
+ /// Accepts a pasted glyph character as-is, or a Unicode code point typed as
+ /// hex — bare ("E8EC") or prefixed ("U+E8EC", "0xE8EC", "").
+ ///
+ public static class GlyphInput
+ {
+ private static readonly string[] _prefixes =
+ ["U+", "u+", "\\u", "\\U", "0x", "0X", "", ""];
+
+ ///
+ /// Returns the resolved glyph character, or when
+ /// the input is blank. Input that isn't a recognised code point is returned
+ /// trimmed but otherwise unchanged.
+ ///
+ public static string Resolve(string? input)
+ {
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ return string.Empty;
+ }
+
+ var trimmed = input.Trim();
+ var hex = trimmed;
+ foreach (var prefix in _prefixes)
+ {
+ if (hex.StartsWith(prefix, StringComparison.Ordinal))
+ {
+ hex = hex[prefix.Length..];
+ break;
+ }
+ }
+ hex = hex.TrimEnd(';');
+
+ // Only treat 2–6 hex digits as a code point; a single character is taken
+ // literally (it's almost certainly a pasted glyph, not a typed code).
+ return hex.Length is >= 2 and <= 6
+ && hex.All(Uri.IsHexDigit)
+ && int.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var codePoint)
+ && codePoint > 0
+ && codePoint <= 0x10FFFF
+ && codePoint is < 0xD800 or > 0xDFFF
+ ? char.ConvertFromUtf32(codePoint)
+ : trimmed;
+ }
+ }
+}
diff --git a/src/Snipdeck.Core/ViewModels/HomeViewModel.cs b/src/Snipdeck.Core/ViewModels/HomeViewModel.cs
index 4cfde9c..c0ce791 100644
--- a/src/Snipdeck.Core/ViewModels/HomeViewModel.cs
+++ b/src/Snipdeck.Core/ViewModels/HomeViewModel.cs
@@ -1,31 +1,104 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
using Snipdeck.Core.Models;
using Snipdeck.Core.Services;
namespace Snipdeck.Core.ViewModels
{
+ /// The snip category shown by the Home page's segmented selector.
+ public enum HomeSnipCategory
+ {
+ MostUsed,
+ Recent,
+ Favourites,
+ }
+
public sealed partial class HomeViewModel : ObservableObject
{
- public const int MostUsedLimit = 6;
+ /// How many snips each Home category shows.
+ public const int CategoryLimit = 9;
public HomeViewModel(SnipStoreDocument document, string? searchText)
{
ArgumentNullException.ThrowIfNull(document);
CliCards = new ObservableCollection(BuildCliCards(document, searchText));
- MostUsedSnips = new ObservableCollection(BuildMostUsedSnips(document, searchText));
+
+ // Category lists are unscoped — they draw from every CLI's snips.
+ var snips = SnipFilter.Apply(document.Snips, searchText, selectedTag: null).ToList();
+
+ MostUsedSnips = new ObservableCollection(
+ snips.Where(s => s.UsageCount > 0)
+ .OrderByDescending(s => s.UsageCount)
+ .ThenByDescending(s => s.LastUsedAt ?? DateTimeOffset.MinValue)
+ .Take(CategoryLimit)
+ .Select(s => new SnipCardViewModel(s)));
+
+ RecentSnips = new ObservableCollection(
+ snips.Where(s => s.LastUsedAt is not null)
+ .OrderByDescending(s => s.LastUsedAt)
+ .Take(CategoryLimit)
+ .Select(s => new SnipCardViewModel(s)));
+
+ FavouriteSnips = new ObservableCollection(
+ snips.Where(s => s.IsFavourite)
+ .OrderByDescending(s => s.LastUsedAt ?? DateTimeOffset.MinValue)
+ .ThenBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
+ .Take(CategoryLimit)
+ .Select(s => new SnipCardViewModel(s)));
}
public ObservableCollection CliCards { get; }
public ObservableCollection MostUsedSnips { get; }
+ public ObservableCollection RecentSnips { get; }
+
+ public ObservableCollection FavouriteSnips { get; }
+
public bool HasCliCards => CliCards.Count > 0;
- public bool HasMostUsedSnips => MostUsedSnips.Count > 0;
+ [ObservableProperty]
+ public partial HomeSnipCategory SelectedCategory { get; set; } = HomeSnipCategory.MostUsed;
+
+ /// The snips shown below the selector, for the chosen category.
+ public ObservableCollection ActiveSnips => SelectedCategory switch
+ {
+ HomeSnipCategory.MostUsed => MostUsedSnips,
+ HomeSnipCategory.Recent => RecentSnips,
+ HomeSnipCategory.Favourites => FavouriteSnips,
+ _ => MostUsedSnips,
+ };
+
+ public bool HasActiveSnips => ActiveSnips.Count > 0;
+
+ // OneWay flags for the selector buttons' checked state.
+ public bool IsMostUsedSelected => SelectedCategory == HomeSnipCategory.MostUsed;
+
+ public bool IsRecentSelected => SelectedCategory == HomeSnipCategory.Recent;
+
+ public bool IsFavouritesSelected => SelectedCategory == HomeSnipCategory.Favourites;
+
+ [RelayCommand]
+ private void SelectMostUsed() => SelectedCategory = HomeSnipCategory.MostUsed;
+
+ [RelayCommand]
+ private void SelectRecent() => SelectedCategory = HomeSnipCategory.Recent;
+
+ [RelayCommand]
+ private void SelectFavourites() => SelectedCategory = HomeSnipCategory.Favourites;
+
+ partial void OnSelectedCategoryChanged(HomeSnipCategory value)
+ {
+ OnPropertyChanged(nameof(ActiveSnips));
+ OnPropertyChanged(nameof(HasActiveSnips));
+ OnPropertyChanged(nameof(IsMostUsedSelected));
+ OnPropertyChanged(nameof(IsRecentSelected));
+ OnPropertyChanged(nameof(IsFavouritesSelected));
+ }
private static IEnumerable BuildCliCards(SnipStoreDocument document, string? searchText)
{
@@ -49,16 +122,5 @@ private static IEnumerable BuildCliCards(SnipStoreDocument doc
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/ParameterDisplayViewModel.cs b/src/Snipdeck.Core/ViewModels/ParameterDisplayViewModel.cs
new file mode 100644
index 0000000..05aebdf
--- /dev/null
+++ b/src/Snipdeck.Core/ViewModels/ParameterDisplayViewModel.cs
@@ -0,0 +1,33 @@
+using Snipdeck.Core.Models;
+
+namespace Snipdeck.Core.ViewModels
+{
+ /// Read-only display of a shared parameter, shown as a card.
+ public sealed class ParameterDisplayViewModel
+ {
+ public ParameterDisplayViewModel(Parameter parameter)
+ {
+ ArgumentNullException.ThrowIfNull(parameter);
+
+ Name = parameter.Name;
+ IsChoice = parameter.Type == ParameterType.Choice;
+ TypeDisplay = IsChoice ? "Choice" : "Text";
+ Default = parameter.Default ?? string.Empty;
+ OptionsDisplay = string.Join(", ", parameter.Options);
+ }
+
+ public string Name { get; }
+
+ public string TypeDisplay { get; }
+
+ public bool IsChoice { get; }
+
+ public string Default { get; }
+
+ public bool HasDefault => !string.IsNullOrEmpty(Default);
+
+ public string OptionsDisplay { get; }
+
+ public bool HasOptions => IsChoice && !string.IsNullOrEmpty(OptionsDisplay);
+ }
+}
diff --git a/src/Snipdeck.Core/ViewModels/SharedParametersViewModel.cs b/src/Snipdeck.Core/ViewModels/SharedParametersViewModel.cs
new file mode 100644
index 0000000..408e7ed
--- /dev/null
+++ b/src/Snipdeck.Core/ViewModels/SharedParametersViewModel.cs
@@ -0,0 +1,36 @@
+using System.Collections.ObjectModel;
+
+using Snipdeck.Core.Models;
+
+namespace Snipdeck.Core.ViewModels
+{
+ ///
+ /// Read-only "Shared parameters" content view: lists the definitions as cards.
+ /// Editing happens in a modal (the shell's EditSharedParameters command).
+ /// Used for both the global set () and a single CLI's set.
+ ///
+ public sealed class SharedParametersViewModel
+ {
+ public SharedParametersViewModel(string title, string description, bool isGlobal, IReadOnlyList parameters)
+ {
+ ArgumentNullException.ThrowIfNull(parameters);
+
+ Title = title;
+ Description = description;
+ IsGlobal = isGlobal;
+ Parameters = new ObservableCollection(
+ parameters.Select(p => new ParameterDisplayViewModel(p)));
+ }
+
+ public string Title { get; }
+
+ public string Description { get; }
+
+ /// True for the cross-CLI global set; false for a single CLI's set.
+ public bool IsGlobal { get; }
+
+ public ObservableCollection Parameters { get; }
+
+ public bool IsEmpty => Parameters.Count == 0;
+ }
+}
diff --git a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs
index 493b24a..c3fd238 100644
--- a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs
+++ b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs
@@ -19,13 +19,23 @@ public sealed partial class ShellViewModel : ObservableObject
{
public const string AllTagsSentinel = "All";
+ /// The project documentation opened by the "Documentation" nav item.
+ public const string DocumentationUrl = "https://github.com/StuartMeeks/Snipdeck#readme";
+
+ // Glyph for the "All" tag entry (Segoe Fluent Icons "Filter").
+ private const string _allTagsGlyph = "\uE71C";
+
private readonly ISnipStore _store;
private readonly IClipboardService _clipboard;
private readonly IClock _clock;
private readonly IShellInteractions _interactions;
private readonly IIconAssetStorage _iconStorage;
+ private readonly IExternalLinkService _externalLinks;
private SnipStoreDocument _document = new();
private bool _suppressShellRefresh;
+ // When set (via a chosen search result), the snip list shows exactly this
+ // snip rather than every title/tag match. Cleared by any other navigation.
+ private Guid? _focusedSnipId;
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
@@ -36,6 +46,9 @@ public sealed partial class ShellViewModel : ObservableObject
[ObservableProperty]
public partial string? SelectedTag { get; set; }
+ [ObservableProperty]
+ public partial TagItemViewModel? SelectedTagItem { get; set; }
+
[ObservableProperty]
public partial object? CurrentContent { get; set; }
@@ -44,24 +57,27 @@ public ShellViewModel(
IClipboardService clipboard,
IClock clock,
IShellInteractions interactions,
- IIconAssetStorage iconStorage)
+ IIconAssetStorage iconStorage,
+ IExternalLinkService externalLinks)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(clipboard);
ArgumentNullException.ThrowIfNull(clock);
ArgumentNullException.ThrowIfNull(interactions);
ArgumentNullException.ThrowIfNull(iconStorage);
+ ArgumentNullException.ThrowIfNull(externalLinks);
_store = store;
_clipboard = clipboard;
_clock = clock;
_interactions = interactions;
_iconStorage = iconStorage;
+ _externalLinks = externalLinks;
}
public ObservableCollection CliChoices { get; } = [];
- public ObservableCollection Tags { get; } = [];
+ public ObservableCollection Tags { get; } = [];
public bool CanCreateNewSnip => SelectedCliChoice?.Cli is not null;
@@ -71,8 +87,32 @@ public async Task LoadAsync(CancellationToken cancellationToken = default)
// an ObservableCollection that XAML is already bound to, and WinRT
// collection-change marshalling requires the original thread.
_document = await _store.LoadAsync(cancellationToken).ConfigureAwait(true);
+ // Tags are matched case-insensitively throughout the shell, so the
+ // persisted tag-icon map (deserialised with an ordinal comparer) is
+ // re-keyed case-insensitively. Built manually so any stray casing
+ // duplicates collapse (last wins) instead of throwing.
+ var tagIcons = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var (tag, glyph) in _document.TagIcons)
+ {
+ tagIcons[tag] = glyph;
+ }
+ _document.TagIcons = tagIcons;
RebuildCliChoices();
- SelectedCliChoice = CliChoices.FirstOrDefault();
+ // Start on Home (no tag selected), scope = "All". Suppress so setting
+ // the choice doesn't auto-switch to the snip list (that's the
+ // user-driven behaviour); build the All-scope tag list explicitly.
+ _suppressShellRefresh = true;
+ try
+ {
+ SelectedCliChoice = CliChoices.FirstOrDefault();
+ RebuildTags();
+ SelectedTagItem = null;
+ }
+ finally
+ {
+ _suppressShellRefresh = false;
+ }
+ ApplyShellContent();
}
public void OpenSettings(SettingsViewModel settings)
@@ -86,30 +126,266 @@ public void OpenTrash()
CurrentContent = BuildTrashViewModel();
}
+ // Scope of the Shared-parameters view currently shown: null = global, else
+ // the CLI whose parameters are being viewed/edited.
+ private Guid? _sharedParametersCliId;
+
public void OpenGlobalParameters()
{
- CurrentContent = new GlobalParametersViewModel(_document.GlobalParameters);
+ _sharedParametersCliId = null;
+ CurrentContent = BuildSharedParametersView();
}
+ /// Open the read-only shared-parameters screen for the current CLI scope.
[RelayCommand]
- private async Task SaveGlobalParametersAsync()
+ private void OpenCliParameters()
{
- if (CurrentContent is not GlobalParametersViewModel globals)
+ if (SelectedCliChoice?.Cli is not { } cli)
{
return;
}
- // Global parameters only affect fill-time resolution (read from the
- // document on copy), so no shell rebuild is needed — just persist.
- _document.GlobalParameters = globals.BuildParameters();
+ _sharedParametersCliId = cli.Id;
+ CurrentContent = BuildSharedParametersView();
+ }
+
+ private SharedParametersViewModel BuildSharedParametersView()
+ {
+ return _sharedParametersCliId is { } cliId
+ && _document.Clis.FirstOrDefault(c => c.Id == cliId) is { } cli
+ ? new SharedParametersViewModel(
+ $"{cli.Name} — shared parameters",
+ "Inherited by every snip under this CLI whose {token} matches, unless the snip defines that parameter locally.",
+ isGlobal: false,
+ cli.Parameters)
+ : new SharedParametersViewModel(
+ "Shared parameters",
+ "Definitions available to every snip across all CLIs. A snip inherits one when its {token} matches and neither the snip nor its CLI defines that name.",
+ isGlobal: true,
+ _document.GlobalParameters);
+ }
+
+ // The live parameter list backing the current Shared-parameters view.
+ private List? CurrentSharedParameterList()
+ {
+ return CurrentContent is not SharedParametersViewModel view
+ ? null
+ : view.IsGlobal
+ ? _document.GlobalParameters
+ : _document.Clis.FirstOrDefault(c => c.Id == _sharedParametersCliId)?.Parameters;
+ }
+
+ // Shared parameters only affect fill-time resolution, so just persist and
+ // rebuild the read-only view — no full shell refresh needed.
+ private async Task PersistSharedParametersAsync()
+ {
await _store.SaveAsync(_document).ConfigureAwait(true);
- globals.StatusMessage = "Saved.";
+ CurrentContent = BuildSharedParametersView();
+ }
+
+ [RelayCommand]
+ private async Task AddSharedParameterAsync()
+ {
+ if (CurrentSharedParameterList() is not { } list)
+ {
+ return;
+ }
+ var added = await _interactions.EditParameterAsync("Add parameter", existing: null).ConfigureAwait(true);
+ if (added is null)
+ {
+ return;
+ }
+ list.Add(added);
+ await PersistSharedParametersAsync().ConfigureAwait(true);
+ }
+
+ [RelayCommand]
+ private async Task EditSharedParameterAsync(ParameterDisplayViewModel? row)
+ {
+ if (row is null
+ || CurrentContent is not SharedParametersViewModel view
+ || CurrentSharedParameterList() is not { } list)
+ {
+ return;
+ }
+ var index = view.Parameters.IndexOf(row);
+ if (index < 0 || index >= list.Count)
+ {
+ return;
+ }
+ var edited = await _interactions.EditParameterAsync("Edit parameter", list[index]).ConfigureAwait(true);
+ if (edited is null)
+ {
+ return;
+ }
+ list[index] = edited;
+ await PersistSharedParametersAsync().ConfigureAwait(true);
}
- public void GoHome()
+ [RelayCommand]
+ private async Task DeleteSharedParameterAsync(ParameterDisplayViewModel? row)
{
- SelectedCliChoice = CliChoices.FirstOrDefault(c => c.IsHome);
+ if (row is null
+ || CurrentContent is not SharedParametersViewModel view
+ || CurrentSharedParameterList() is not { } list)
+ {
+ return;
+ }
+ var index = view.Parameters.IndexOf(row);
+ if (index < 0 || index >= list.Count)
+ {
+ return;
+ }
+ list.RemoveAt(index);
+ await PersistSharedParametersAsync().ConfigureAwait(true);
+ }
+
+ public void OpenTagIcons()
+ {
+ CurrentContent = new TagIconsViewModel(SnipFilter.DistinctTagsFor(_document.Snips), _document.TagIcons);
}
+ [RelayCommand]
+ private async Task SaveTagIconsAsync()
+ {
+ if (CurrentContent is not TagIconsViewModel tags)
+ {
+ return;
+ }
+ _document.TagIcons = tags.BuildTagIcons();
+ await _store.SaveAsync(_document).ConfigureAwait(true);
+
+ // Refresh the left-nav glyphs in place, keeping the user on this view
+ // (suppress the content swap a selection change would otherwise cause).
+ var wasHome = SelectedTagItem is null;
+ var previousTagName = SelectedTagItem?.Name;
+ _suppressShellRefresh = true;
+ try
+ {
+ RebuildTags();
+ RestoreTagSelection(wasHome, previousTagName);
+ }
+ finally
+ {
+ _suppressShellRefresh = false;
+ }
+ tags.StatusMessage = "Saved.";
+ }
+
+ ///
+ /// Show the Home launcher: reset the switcher to the "All" scope and clear
+ /// the tag selection. Done under suppression so switching scope doesn't bounce
+ /// to the snip list before Home is applied.
+ ///
+ public void ShowHome()
+ {
+ _suppressShellRefresh = true;
+ try
+ {
+ SelectedCliChoice = CliChoices.FirstOrDefault(c => c.IsAll) ?? CliChoices.FirstOrDefault();
+ RebuildTags();
+ SelectedTagItem = null;
+ SearchText = string.Empty;
+ _focusedSnipId = null;
+ }
+ finally
+ {
+ _suppressShellRefresh = false;
+ }
+ ApplyShellContent();
+ OnPropertyChanged(nameof(CanCreateNewSnip));
+ }
+
+ ///
+ /// Select a tag from the nav. Re-applies the snip list even when the tag is
+ /// already selected (e.g. re-invoked from Settings/Trash), since the property
+ /// setter alone wouldn't raise a change and refresh the content.
+ ///
+ public void SelectTag(TagItemViewModel tag)
+ {
+ ArgumentNullException.ThrowIfNull(tag);
+ _focusedSnipId = null;
+ if (ReferenceEquals(SelectedTagItem, tag))
+ {
+ ApplyShellContent();
+ }
+ else
+ {
+ SelectedTagItem = tag;
+ }
+ }
+
+ /// Open the project documentation (GitHub readme) in the browser.
+ public Task OpenDocumentationAsync() => _externalLinks.OpenAsync(DocumentationUrl);
+
+ ///
+ /// Snip-name autocomplete for the title-bar search, scoped to the current
+ /// CLI switcher value. Each result carries its CLI name for the badge.
+ ///
+ public IReadOnlyList GetSearchSuggestions(string query)
+ {
+ if (string.IsNullOrWhiteSpace(query))
+ {
+ return [];
+ }
+ var trimmed = query.Trim();
+ return [.. ScopedSnips()
+ .Where(s => !s.IsTrash && s.Title.Contains(trimmed, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(s => s.Title, StringComparer.OrdinalIgnoreCase)
+ .Select(s => new SnipSearchResult(s.Title, CliNameFor(s.CliId), s.CliId, s.Id))];
+ }
+
+ /// Filter the snip list down to a chosen search result, switching CLI scope if needed.
+ public void SelectSearchResult(SnipSearchResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+
+ // Move to the chosen snip's CLI scope and show the snip list, then
+ // constrain it to exactly that snip — all under suppression so the
+ // property handlers don't clear the focus or refresh twice.
+ _suppressShellRefresh = true;
+ try
+ {
+ var choice = CliChoices.FirstOrDefault(c => c.Cli?.Id == result.CliId);
+ if (choice is not null)
+ {
+ SelectedCliChoice = choice;
+ }
+ RebuildTags();
+ SelectedTagItem = Tags.FirstOrDefault(t => t.IsAll);
+ _focusedSnipId = result.SnipId;
+ SearchText = result.Title;
+ }
+ finally
+ {
+ _suppressShellRefresh = false;
+ }
+ ApplyShellContent();
+ }
+
+ /// Filter the snip list by free-text search, moving off Home (or any
+ /// non-snip page like Settings/Trash) to the snip list.
+ public void ApplySearch(string query)
+ {
+ // Set state under suppression, then apply once — so submitting the same
+ // query again still swaps a non-snip page back to the snip list (the
+ // property assignments alone would be no-ops and skip the refresh).
+ _suppressShellRefresh = true;
+ try
+ {
+ SelectedTagItem ??= Tags.FirstOrDefault(t => t.IsAll);
+ SearchText = query ?? string.Empty;
+ _focusedSnipId = null;
+ }
+ finally
+ {
+ _suppressShellRefresh = false;
+ }
+ ApplyShellContent();
+ }
+
+ private string CliNameFor(Guid cliId) =>
+ _document.Clis.FirstOrDefault(c => c.Id == cliId)?.Name ?? string.Empty;
+
[RelayCommand]
private async Task CopySnipAsync(SnipCardViewModel? cardVm)
{
@@ -183,7 +459,8 @@ private async Task DeleteSnipAsync(SnipCardViewModel? cardVm)
"Delete snip",
$"Move “{cardVm.Snip.Title}” to trash?",
"Delete",
- "Cancel").ConfigureAwait(true);
+ "Cancel",
+ destructive: true).ConfigureAwait(true);
if (!confirmed)
{
return;
@@ -214,7 +491,8 @@ private async Task DeleteForeverAsync(SnipCardViewModel? cardVm)
"Delete permanently",
$"Permanently delete “{cardVm.Snip.Title}”? This can't be undone.",
"Delete",
- "Cancel").ConfigureAwait(true);
+ "Cancel",
+ destructive: true).ConfigureAwait(true);
if (!confirmed)
{
return;
@@ -271,6 +549,7 @@ private async Task NewCliAsync()
{
Id = saved.Id,
Name = saved.Name,
+ Description = saved.Description,
IconRef = await _iconStorage.SaveIconAsync(saved.Id, bytes).ConfigureAwait(true),
Parameters = saved.Parameters,
};
@@ -316,6 +595,7 @@ private async Task EditCurrentCliAsync()
{
Id = updated.Id,
Name = updated.Name,
+ Description = updated.Description,
IconRef = await _iconStorage.SaveIconAsync(updated.Id, bytes).ConfigureAwait(true),
Parameters = updated.Parameters,
};
@@ -355,7 +635,8 @@ await _interactions.NotifyAsync(
"Delete CLI",
$"Delete “{cli.Name}”? This can't be undone.",
"Delete",
- "Cancel").ConfigureAwait(true);
+ "Cancel",
+ destructive: true).ConfigureAwait(true);
if (!confirmed)
{
return;
@@ -366,9 +647,11 @@ await _interactions.NotifyAsync(
_ = _document.Snips.RemoveAll(s => s.CliId == cli.Id);
_ = _document.Clis.RemoveAll(c => c.Id == cli.Id);
- // Persist the removal first; the deleted CLI is no longer in CliChoices
- // so SaveAndRefreshAsync falls back to the first choice (Home).
+ // Persist the removal, then go Home: the CLI the user was viewing is
+ // gone, so returning to the snip list (the All scope) would otherwise
+ // strand them on an empty page with no New CLI call-to-action.
await SaveAndRefreshAsync().ConfigureAwait(true);
+ ShowHome();
// Only after the store is safely persisted do we clean up the icon —
// a best-effort side effect. Doing it earlier would risk deleting the
@@ -381,11 +664,19 @@ await _interactions.NotifyAsync(
partial void OnSelectedCliChoiceChanged(CliChoice? value)
{
+ // The initial/programmatic set is orchestrated by the caller (LoadAsync /
+ // SaveAndRefresh) under suppression; only react to user switcher changes.
+ if (_suppressShellRefresh)
+ {
+ return;
+ }
+ _focusedSnipId = null; // a user CLI switch drops any focused search result
_suppressShellRefresh = true;
try
{
RebuildTags();
- SelectedTag = Tags.Count > 0 ? AllTagsSentinel : null;
+ // Changing the CLI shows that scope's snips (the "All" tag).
+ SelectedTagItem = Tags.FirstOrDefault(t => t.IsAll);
}
finally
{
@@ -401,57 +692,87 @@ partial void OnSelectedTagChanged(string? value)
{
return;
}
+ _focusedSnipId = null; // changing the tag filter drops any focused search result
ApplyShellContent();
}
+ // The nav binds its selection to SelectedTagItem; mirror it onto the
+ // SelectedTag filter string (the "All" item maps to the sentinel).
+ partial void OnSelectedTagItemChanged(TagItemViewModel? value)
+ {
+ SelectedTag = value?.Name;
+ }
+
partial void OnSearchTextChanged(string value)
{
if (_suppressShellRefresh)
{
return;
}
+ _focusedSnipId = null; // typing a new search drops any focused search result
ApplyShellContent();
}
private void RebuildCliChoices()
{
CliChoices.Clear();
- CliChoices.Add(new CliChoice { Display = "All / Home" });
+ CliChoices.Add(new CliChoice { Display = "All" });
foreach (var cli in _document.Clis.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))
{
CliChoices.Add(new CliChoice { Cli = cli, Display = cli.Name });
}
}
+ // Snips in the current switcher scope: a single CLI, or all CLIs when "All".
+ private IEnumerable ScopedSnips() =>
+ SelectedCliChoice?.Cli is { } cli
+ ? _document.Snips.Where(s => s.CliId == cli.Id)
+ : _document.Snips;
+
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)
+ Tags.Add(new TagItemViewModel(AllTagsSentinel, _allTagsGlyph, isAll: true));
+ foreach (var tag in SnipFilter.DistinctTagsFor(ScopedSnips())
.OrderBy(t => t, StringComparer.OrdinalIgnoreCase))
{
- Tags.Add(tag);
+ var glyph = _document.TagIcons.TryGetValue(tag, out var g) && !string.IsNullOrWhiteSpace(g)
+ ? g
+ : TagItemViewModel.DefaultGlyph;
+ Tags.Add(new TagItemViewModel(tag, glyph));
}
}
private void ApplyShellContent()
{
- if (SelectedCliChoice?.Cli is { } cli)
+ // No tag selected => the Home launcher. A tag (or "All") => the snip
+ // list for the current scope, filtered by that tag and the search text.
+ if (SelectedTagItem is null)
+ {
+ _focusedSnipId = null; // Home shows the launcher; drop any focused snip.
+ var home = new HomeViewModel(_document, SearchText);
+ // Preserve the selected category across save-driven refreshes so a
+ // card action (copy / favourite / delete) doesn't jump back to Most used.
+ if (CurrentContent is HomeViewModel previous)
+ {
+ home.SelectedCategory = previous.SelectedCategory;
+ }
+ CurrentContent = home;
+ return;
+ }
+
+ List filtered;
+ if (_focusedSnipId is { } focusId)
{
- 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);
+ // A chosen search result: show exactly that snip (never a trashed one).
+ filtered = [.. ScopedSnips().Where(s => s.Id == focusId && !s.IsTrash)];
}
else
{
- CurrentContent = new HomeViewModel(_document, SearchText);
+ var effectiveTag = SelectedTagItem.IsAll ? null : SelectedTagItem.Name;
+ filtered = [.. SnipFilter.Apply(ScopedSnips(), SearchText, effectiveTag)];
}
+ CurrentContent = new CliViewModel(SelectedCliChoice?.Cli, filtered);
}
private TrashViewModel BuildTrashViewModel()
@@ -472,11 +793,13 @@ private async Task SaveAndRefreshTrashAsync()
{
await _store.SaveAsync(_document).ConfigureAwait(true);
+ var wasHome = SelectedTagItem is null;
+ var previousTagName = SelectedTagItem?.Name;
_suppressShellRefresh = true;
try
{
RebuildTags();
- SelectedTag = Tags.Count > 0 ? AllTagsSentinel : null;
+ RestoreTagSelection(wasHome, previousTagName);
}
finally
{
@@ -490,6 +813,8 @@ private async Task SaveAndRefreshAsync()
{
await _store.SaveAsync(_document).ConfigureAwait(true);
var previousCliId = SelectedCliChoice?.Cli?.Id;
+ var wasHome = SelectedTagItem is null;
+ var previousTagName = SelectedTagItem?.Name;
_suppressShellRefresh = true;
try
@@ -498,7 +823,7 @@ private async Task SaveAndRefreshAsync()
SelectedCliChoice = CliChoices.FirstOrDefault(c => c.Cli?.Id == previousCliId)
?? CliChoices.FirstOrDefault();
RebuildTags();
- SelectedTag = Tags.Count > 0 ? AllTagsSentinel : null;
+ RestoreTagSelection(wasHome, previousTagName);
}
finally
{
@@ -506,5 +831,16 @@ private async Task SaveAndRefreshAsync()
}
ApplyShellContent();
}
+
+ // Re-apply the prior nav selection after a tag rebuild: stay on Home when
+ // the user was on Home, otherwise reselect the same tag (falling back to
+ // "All" if that tag no longer exists in scope).
+ private void RestoreTagSelection(bool wasHome, string? previousTagName)
+ {
+ SelectedTagItem = wasHome
+ ? null
+ : Tags.FirstOrDefault(t => string.Equals(t.Name, previousTagName, StringComparison.Ordinal))
+ ?? Tags.FirstOrDefault(t => t.IsAll);
+ }
}
}
diff --git a/src/Snipdeck.Core/ViewModels/SnipSearchResult.cs b/src/Snipdeck.Core/ViewModels/SnipSearchResult.cs
new file mode 100644
index 0000000..2b30803
--- /dev/null
+++ b/src/Snipdeck.Core/ViewModels/SnipSearchResult.cs
@@ -0,0 +1,8 @@
+namespace Snipdeck.Core.ViewModels
+{
+ ///
+ /// A snip-search autocomplete suggestion. is shown as a
+ /// badge so identically-named snips in different CLIs are distinguishable.
+ ///
+ public sealed record SnipSearchResult(string Title, string CliName, Guid CliId, Guid SnipId);
+}
diff --git a/src/Snipdeck.Core/ViewModels/TagIconsViewModel.cs b/src/Snipdeck.Core/ViewModels/TagIconsViewModel.cs
new file mode 100644
index 0000000..37368ef
--- /dev/null
+++ b/src/Snipdeck.Core/ViewModels/TagIconsViewModel.cs
@@ -0,0 +1,82 @@
+using System.Collections.ObjectModel;
+
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace Snipdeck.Core.ViewModels
+{
+ /// One editable row in the "Tags" management view: a tag and its icon glyph.
+ public sealed partial class TagIconRowViewModel(string tagName, string glyph) : ObservableObject
+ {
+ public string TagName { get; } = tagName;
+
+ /// The raw glyph the user has entered; empty means "use the default".
+ [ObservableProperty]
+ public partial string Glyph { get; set; } = glyph;
+
+ ///
+ /// What to show in the preview — the resolved glyph (a typed code point like
+ /// "E8EC" becomes its character), or the default when blank.
+ ///
+ public string PreviewGlyph
+ {
+ get
+ {
+ var resolved = GlyphInput.Resolve(Glyph);
+ return resolved.Length == 0 ? TagItemViewModel.DefaultGlyph : resolved;
+ }
+ }
+
+ partial void OnGlyphChanged(string value) => OnPropertyChanged(nameof(PreviewGlyph));
+ }
+
+ ///
+ /// The "Tags" content view (left-pane footer): lists every tag in use, each
+ /// with an editable icon glyph. The shell persists the result to the store's
+ /// global tag-icon map.
+ ///
+ public sealed partial class TagIconsViewModel : ObservableObject
+ {
+ public TagIconsViewModel(IEnumerable tagNames, IReadOnlyDictionary tagIcons)
+ {
+ ArgumentNullException.ThrowIfNull(tagNames);
+ ArgumentNullException.ThrowIfNull(tagIcons);
+
+ // Tags are matched case-insensitively across the shell, so collapse
+ // casing variants to a single editable row (avoids a saved icon
+ // appearing not to apply to the nav's collapsed tag entry).
+ Rows = new ObservableCollection(
+ tagNames
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(t => t, StringComparer.OrdinalIgnoreCase)
+ .Select(t => new TagIconRowViewModel(t, tagIcons.TryGetValue(t, out var g) ? g : string.Empty)));
+ }
+
+ public ObservableCollection Rows { get; }
+
+ public bool IsEmpty => Rows.Count == 0;
+
+ [ObservableProperty]
+ public partial string StatusMessage { get; set; } = string.Empty;
+
+ ///
+ /// The tag→glyph map to persist: only rows with a non-default glyph, so
+ /// default-glyph tags stay implicit and the map stays small. Keyed
+ /// case-insensitively to match how tags are matched across the shell.
+ ///
+ public Dictionary BuildTagIcons()
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var row in Rows)
+ {
+ // Store the resolved character, so a typed code point ("E8EC") is
+ // persisted (and later rendered) as its glyph.
+ var glyph = GlyphInput.Resolve(row.Glyph);
+ if (glyph.Length != 0 && glyph != TagItemViewModel.DefaultGlyph)
+ {
+ map[row.TagName] = glyph;
+ }
+ }
+ return map;
+ }
+ }
+}
diff --git a/src/Snipdeck.Core/ViewModels/TagItemViewModel.cs b/src/Snipdeck.Core/ViewModels/TagItemViewModel.cs
new file mode 100644
index 0000000..6d8eb2b
--- /dev/null
+++ b/src/Snipdeck.Core/ViewModels/TagItemViewModel.cs
@@ -0,0 +1,23 @@
+namespace Snipdeck.Core.ViewModels
+{
+ ///
+ /// A tag entry in the left-navigation tag list: the tag name plus its icon
+ /// glyph (Segoe Fluent Icons). The "All" sentinel is also represented here.
+ /// Immutable — the nav refreshes by rebuilding the whole tag collection.
+ ///
+ public sealed class TagItemViewModel(string name, string glyph, bool isAll = false)
+ {
+ ///
+ /// Fallback glyph for a tag with no assigned icon: the Segoe Fluent Icons
+ /// "Tag" glyph (same as the Tags feature icon). A literal "#" renders as
+ /// tofu in that icon font, so we use a real glyph.
+ ///
+ public const string DefaultGlyph = "\uE8EC";
+
+ public string Name { get; } = name;
+
+ public string Glyph { get; } = glyph;
+
+ public bool IsAll { get; } = isAll;
+ }
+}
diff --git a/tests/Snipdeck.Core.Tests/Support/FakeExternalLinkService.cs b/tests/Snipdeck.Core.Tests/Support/FakeExternalLinkService.cs
new file mode 100644
index 0000000..7541940
--- /dev/null
+++ b/tests/Snipdeck.Core.Tests/Support/FakeExternalLinkService.cs
@@ -0,0 +1,18 @@
+using Snipdeck.Core.Abstractions;
+
+namespace Snipdeck.Core.Tests.Support
+{
+ public sealed class FakeExternalLinkService : IExternalLinkService
+ {
+ public string? LastOpenedUrl { get; private set; }
+
+ public int OpenCount { get; private set; }
+
+ public Task OpenAsync(string url)
+ {
+ LastOpenedUrl = url;
+ OpenCount++;
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs b/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs
index 70a626a..9752b28 100644
--- a/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs
+++ b/tests/Snipdeck.Core.Tests/Support/FakeShellInteractions.cs
@@ -18,8 +18,16 @@ public sealed class FakeShellInteractions : IShellInteractions
public ParameterFillResult? NextParameterFillResult { get; set; }
+ public Parameter? NextEditParameterResult { get; set; }
+
+ public string? LastEditParameterTitle { get; private set; }
+
+ public Parameter? LastEditParameterExisting { get; private set; }
+
public string? LastConfirmTitle { get; private set; }
+ public bool LastConfirmDestructive { get; private set; }
+
public string? LastNotifyTitle { get; private set; }
public string? LastNotifyMessage { get; private set; }
@@ -34,9 +42,10 @@ public sealed class FakeShellInteractions : IShellInteractions
public IReadOnlyList? LastFilledParameters { get; private set; }
- public Task ConfirmAsync(string title, string message, string confirmButtonText = "Yes", string cancelButtonText = "Cancel")
+ public Task ConfirmAsync(string title, string message, string confirmButtonText = "Yes", string cancelButtonText = "Cancel", bool destructive = false)
{
LastConfirmTitle = title;
+ LastConfirmDestructive = destructive;
return Task.FromResult(NextConfirmResult);
}
@@ -60,6 +69,13 @@ public Task NotifyAsync(string title, string message, string buttonText = "OK")
return Task.FromResult(NextCliEditResult);
}
+ public Task EditParameterAsync(string title, Parameter? existing)
+ {
+ LastEditParameterTitle = title;
+ LastEditParameterExisting = existing;
+ return Task.FromResult(NextEditParameterResult);
+ }
+
public Task FillParametersAsync(Snip snip, IReadOnlyList parameters)
{
LastFilledSnip = snip;
diff --git a/tests/Snipdeck.Core.Tests/ViewModels/CliEditorViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/CliEditorViewModelTests.cs
index 52062d5..bcc5871 100644
--- a/tests/Snipdeck.Core.Tests/ViewModels/CliEditorViewModelTests.cs
+++ b/tests/Snipdeck.Core.Tests/ViewModels/CliEditorViewModelTests.cs
@@ -18,6 +18,18 @@ public void BuildUpdatedCli_applies_the_edited_name_and_keeps_id_and_icon()
Assert.Equal("icons/pl.png", updated.IconRef);
}
+ [Fact]
+ public void BuildUpdatedCli_round_trips_the_trimmed_description()
+ {
+ var cli = new Cli { Name = "pl-app", Description = "Platform CLI." };
+ var vm = new CliEditorViewModel(cli);
+
+ Assert.Equal("Platform CLI.", vm.Description);
+
+ vm.Description = " Updated summary. ";
+ Assert.Equal("Updated summary.", vm.BuildUpdatedCli().Description);
+ }
+
[Fact]
public void Loads_and_rebuilds_shared_parameters_through_editor_rows()
{
diff --git a/tests/Snipdeck.Core.Tests/ViewModels/GlobalParametersViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/GlobalParametersViewModelTests.cs
deleted file mode 100644
index 20014cc..0000000
--- a/tests/Snipdeck.Core.Tests/ViewModels/GlobalParametersViewModelTests.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-using Snipdeck.Core.Models;
-using Snipdeck.Core.ViewModels;
-
-namespace Snipdeck.Core.Tests.ViewModels
-{
- public class GlobalParametersViewModelTests
- {
- [Fact]
- public void Loads_existing_global_parameters_into_rows()
- {
- var vm = new GlobalParametersViewModel([new Parameter { Name = "tenant" }]);
- Assert.Equal("tenant", Assert.Single(vm.Parameters).Name);
- }
-
- [Fact]
- public void Add_then_build_yields_the_new_parameter()
- {
- var vm = new GlobalParametersViewModel([]);
-
- vm.AddParameterCommand.Execute(null);
- vm.Parameters[0].Name = "yes_no";
- vm.Parameters[0].TypeIndex = 1; // Choice
- vm.Parameters[0].OptionsText = "yes, no";
-
- var built = vm.BuildParameters();
- var p = Assert.Single(built);
- Assert.Equal("yes_no", p.Name);
- Assert.Equal(ParameterType.Choice, p.Type);
- Assert.Equal(["yes", "no"], p.Options);
- }
-
- [Fact]
- public void Remove_drops_the_row()
- {
- var vm = new GlobalParametersViewModel([new Parameter { Name = "a" }, new Parameter { Name = "b" }]);
-
- vm.RemoveParameterCommand.Execute(vm.Parameters[0]);
-
- Assert.Equal("b", Assert.Single(vm.Parameters).Name);
- }
- }
-}
diff --git a/tests/Snipdeck.Core.Tests/ViewModels/GlyphInputTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/GlyphInputTests.cs
new file mode 100644
index 0000000..9cbd0a2
--- /dev/null
+++ b/tests/Snipdeck.Core.Tests/ViewModels/GlyphInputTests.cs
@@ -0,0 +1,45 @@
+using Snipdeck.Core.ViewModels;
+
+namespace Snipdeck.Core.Tests.ViewModels
+{
+ public class GlyphInputTests
+ {
+ private static readonly string _tagGlyph = char.ConvertFromUtf32(0xE8EC);
+
+ [Theory]
+ [InlineData("E8EC")]
+ [InlineData("e8ec")]
+ [InlineData("U+E8EC")]
+ [InlineData("0xE8EC")]
+ [InlineData("\\uE8EC")]
+ [InlineData("")]
+ public void Resolve_converts_a_typed_code_point_to_its_character(string input)
+ {
+ Assert.Equal(_tagGlyph, GlyphInput.Resolve(input));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public void Resolve_returns_empty_for_blank_input(string? input)
+ {
+ Assert.Equal(string.Empty, GlyphInput.Resolve(input));
+ }
+
+ [Fact]
+ public void Resolve_keeps_a_single_pasted_character_literally()
+ {
+ // A lone character is taken as-is — even "#" or an actual pasted glyph —
+ // never reinterpreted as a code point.
+ Assert.Equal("#", GlyphInput.Resolve("#"));
+ Assert.Equal(_tagGlyph, GlyphInput.Resolve(_tagGlyph));
+ }
+
+ [Fact]
+ public void Resolve_keeps_non_hex_text_unchanged()
+ {
+ Assert.Equal("tag", GlyphInput.Resolve(" tag "));
+ }
+ }
+}
diff --git a/tests/Snipdeck.Core.Tests/ViewModels/HomeViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/HomeViewModelTests.cs
index 05c51b6..c9dee88 100644
--- a/tests/Snipdeck.Core.Tests/ViewModels/HomeViewModelTests.cs
+++ b/tests/Snipdeck.Core.Tests/ViewModels/HomeViewModelTests.cs
@@ -94,8 +94,82 @@ public void Empty_document_produces_empty_collections_with_false_predicates()
Assert.Empty(vm.CliCards);
Assert.Empty(vm.MostUsedSnips);
+ Assert.Empty(vm.RecentSnips);
+ Assert.Empty(vm.FavouriteSnips);
Assert.False(vm.HasCliCards);
- Assert.False(vm.HasMostUsedSnips);
+ Assert.False(vm.HasActiveSnips);
+ }
+
+ [Fact]
+ public void RecentSnips_are_ordered_by_last_used_desc_and_exclude_never_used()
+ {
+ 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 = "older", UsageCount = 1, LastUsedAt = now.AddHours(-2) });
+ d.Snips.Add(new Snip { CliId = cli.Id, Title = "newer", UsageCount = 1, LastUsedAt = now });
+ d.Snips.Add(new Snip { CliId = cli.Id, Title = "unused" });
+ });
+
+ var vm = new HomeViewModel(doc, searchText: null);
+
+ Assert.Collection(vm.RecentSnips,
+ s => Assert.Equal("newer", s.Title),
+ s => Assert.Equal("older", s.Title));
+ }
+
+ [Fact]
+ public void FavouriteSnips_contains_only_favourites()
+ {
+ var cli = new Cli { Name = "a" };
+ var doc = Document(d =>
+ {
+ d.Clis.Add(cli);
+ d.Snips.Add(new Snip { CliId = cli.Id, Title = "fav", IsFavourite = true });
+ d.Snips.Add(new Snip { CliId = cli.Id, Title = "plain" });
+ });
+
+ var vm = new HomeViewModel(doc, searchText: null);
+
+ Assert.Equal("fav", Assert.Single(vm.FavouriteSnips).Title);
+ }
+
+ [Fact]
+ public void SelectedCategory_switches_the_active_snip_list()
+ {
+ var cli = new Cli { Name = "a" };
+ var doc = Document(d =>
+ {
+ d.Clis.Add(cli);
+ d.Snips.Add(new Snip { CliId = cli.Id, Title = "used", UsageCount = 3 });
+ d.Snips.Add(new Snip { CliId = cli.Id, Title = "fav", IsFavourite = true });
+ });
+
+ var vm = new HomeViewModel(doc, searchText: null);
+
+ Assert.Same(vm.MostUsedSnips, vm.ActiveSnips); // default
+ Assert.True(vm.IsMostUsedSelected);
+
+ vm.SelectFavouritesCommand.Execute(null);
+
+ Assert.Same(vm.FavouriteSnips, vm.ActiveSnips);
+ Assert.True(vm.IsFavouritesSelected);
+ Assert.False(vm.IsMostUsedSelected);
+ }
+
+ [Fact]
+ public void CliCard_exposes_its_description()
+ {
+ var cli = new Cli { Name = "pl-app", Description = "Platform CLI." };
+ var doc = Document(d => d.Clis.Add(cli));
+
+ var vm = new HomeViewModel(doc, searchText: null);
+
+ var card = Assert.Single(vm.CliCards);
+ Assert.Equal("Platform CLI.", card.Description);
+ Assert.True(card.HasDescription);
}
}
}
diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs
index c9361a4..c4f8da4 100644
--- a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs
+++ b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs
@@ -34,7 +34,7 @@ public Task SaveAsync(SnipStoreDocument document, CancellationToken cancellation
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());
+ var vm = new ShellViewModel(store, clip, clock, ix, new FakeIconAssetStorage(), new FakeExternalLinkService());
await vm.LoadAsync();
return (vm, store, clip, ix, clock);
}
@@ -193,6 +193,7 @@ public async Task DeleteSnip_marks_as_trash_only_when_confirmed()
await vm.DeleteSnipCommand.ExecuteAsync(card);
Assert.True(store.Document.Snips[0].IsTrash);
+ Assert.True(ix.LastConfirmDestructive); // delete confirms get the danger styling
}
[Fact]
@@ -298,7 +299,7 @@ public async Task DeleteCurrentCli_removes_cli_and_its_trashed_snips_then_falls_
Assert.Empty(store.Document.Clis);
Assert.Empty(store.Document.Snips);
Assert.Equal(1, store.SaveCount);
- Assert.True(vm.SelectedCliChoice?.IsHome);
+ Assert.True(vm.SelectedCliChoice?.IsAll);
}
[Fact]
@@ -310,7 +311,7 @@ public async Task DeleteCurrentCli_deletes_the_icon_asset()
doc.Clis.Add(cli);
var store = new InMemorySnipStore(doc);
var ix = new FakeShellInteractions { NextConfirmResult = true };
- var vm = new ShellViewModel(store, new FakeClipboardService(), new FakeClock(DateTimeOffset.UtcNow), ix, icons);
+ var vm = new ShellViewModel(store, new FakeClipboardService(), new FakeClock(DateTimeOffset.UtcNow), ix, icons, new FakeExternalLinkService());
await vm.LoadAsync();
vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id);
@@ -335,22 +336,177 @@ public async Task DeleteCurrentCli_no_ops_on_home()
}
[Fact]
- public async Task SaveGlobalParameters_persists_edited_rows_to_the_document()
+ public async Task SaveTagIcons_persists_glyphs_and_refreshes_the_nav()
{
- var (vm, store, _, _, _) = await BuildAsync();
+ Cli cli = null!;
+ var (vm, store, _, _, _) = await BuildAsync(d =>
+ {
+ cli = new Cli { Name = "pl-app" };
+ d.Clis.Add(cli);
+ d.Snips.Add(new Snip { CliId = cli.Id, Title = "Deploy", CommandTemplate = "x", Tags = ["deploy"] });
+ });
+
+ // Select the CLI so its tags populate the nav (default tag glyph).
+ vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id);
+ Assert.Equal(TagItemViewModel.DefaultGlyph, vm.Tags.Single(t => t.Name == "deploy").Glyph);
+
+ vm.OpenTagIcons();
+ var tagsVm = Assert.IsType(vm.CurrentContent);
+ tagsVm.Rows.Single(r => r.TagName == "deploy").Glyph = "X";
+
+ await vm.SaveTagIconsCommand.ExecuteAsync(null);
+
+ Assert.Equal("X", store.Document.TagIcons["deploy"]);
+ // Nav glyph refreshed in place; the view stays on the Tags editor.
+ Assert.Equal("X", vm.Tags.Single(t => t.Name == "deploy").Glyph);
+ Assert.IsType(vm.CurrentContent);
+ }
+
+ [Fact]
+ public async Task Tag_icons_apply_case_insensitively_to_the_nav()
+ {
+ Cli cli = null!;
+ var (vm, _, _, _, _) = await BuildAsync(d =>
+ {
+ cli = new Cli { Name = "pl-app" };
+ d.Clis.Add(cli);
+ // Snip tag casing differs from the persisted icon-map key.
+ d.Snips.Add(new Snip { CliId = cli.Id, Title = "Deploy", Tags = ["Deploy"] });
+ d.TagIcons["deploy"] = "X";
+ });
+
+ vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id);
+
+ var tag = vm.Tags.Single(t => string.Equals(t.Name, "Deploy", StringComparison.OrdinalIgnoreCase));
+ Assert.Equal("X", tag.Glyph);
+ }
+
+ [Fact]
+ public async Task OpenGlobalParameters_shows_a_global_read_only_view()
+ {
+ var (vm, _, _, _, _) = await BuildAsync(d =>
+ d.GlobalParameters.Add(new Parameter { Name = "tenant", Default = "acme" }));
+
+ vm.OpenGlobalParameters();
+
+ var view = Assert.IsType(vm.CurrentContent);
+ Assert.True(view.IsGlobal);
+ var p = Assert.Single(view.Parameters);
+ Assert.Equal("tenant", p.Name);
+ Assert.Equal("acme", p.Default);
+ }
+ [Fact]
+ public async Task AddSharedParameter_appends_the_modal_result_to_the_global_set()
+ {
+ var (vm, store, _, ix, _) = await BuildAsync();
vm.OpenGlobalParameters();
- var globals = Assert.IsType(vm.CurrentContent);
- globals.AddParameterCommand.Execute(null);
- globals.Parameters[0].Name = "tenant";
- globals.Parameters[0].Default = "acme";
- await vm.SaveGlobalParametersCommand.ExecuteAsync(null);
+ ix.NextEditParameterResult = new Parameter { Name = "tenant", Default = "acme" };
+ await vm.AddSharedParameterCommand.ExecuteAsync(null);
var saved = Assert.Single(store.Document.GlobalParameters);
Assert.Equal("tenant", saved.Name);
Assert.Equal("acme", saved.Default);
- Assert.Equal("Saved.", globals.StatusMessage);
+ Assert.Null(ix.LastEditParameterExisting); // "Add" passes no existing parameter
+ // The read-only view refreshes to show the saved definition.
+ var view = Assert.IsType(vm.CurrentContent);
+ Assert.Equal("tenant", Assert.Single(view.Parameters).Name);
+ }
+
+ [Fact]
+ public async Task AddSharedParameter_appends_to_the_selected_cli_when_cli_scoped()
+ {
+ Cli cli = null!;
+ var (vm, store, _, ix, _) = await BuildAsync(d =>
+ {
+ cli = new Cli { Name = "pl-app" };
+ d.Clis.Add(cli);
+ });
+
+ vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id);
+ vm.OpenCliParametersCommand.Execute(null);
+ Assert.False(Assert.IsType(vm.CurrentContent).IsGlobal);
+
+ ix.NextEditParameterResult = new Parameter { Name = "region", Default = "eu" };
+ await vm.AddSharedParameterCommand.ExecuteAsync(null);
+
+ Assert.Equal("region", Assert.Single(Assert.Single(store.Document.Clis).Parameters).Name);
+ Assert.Empty(store.Document.GlobalParameters);
+ }
+
+ [Fact]
+ public async Task EditSharedParameter_replaces_the_chosen_parameter()
+ {
+ var (vm, store, _, ix, _) = await BuildAsync(d =>
+ {
+ d.GlobalParameters.Add(new Parameter { Name = "a" });
+ d.GlobalParameters.Add(new Parameter { Name = "b" });
+ });
+ vm.OpenGlobalParameters();
+ var view = Assert.IsType(vm.CurrentContent);
+
+ ix.NextEditParameterResult = new Parameter { Name = "b2" };
+ await vm.EditSharedParameterCommand.ExecuteAsync(view.Parameters[1]);
+
+ Assert.Equal("b", ix.LastEditParameterExisting!.Name); // seeded with the chosen one
+ Assert.Equal(["a", "b2"], store.Document.GlobalParameters.Select(p => p.Name));
+ }
+
+ [Fact]
+ public async Task DeleteSharedParameter_removes_the_chosen_parameter()
+ {
+ var (vm, store, _, _, _) = await BuildAsync(d =>
+ {
+ d.GlobalParameters.Add(new Parameter { Name = "a" });
+ d.GlobalParameters.Add(new Parameter { Name = "b" });
+ });
+ vm.OpenGlobalParameters();
+ var view = Assert.IsType(vm.CurrentContent);
+
+ await vm.DeleteSharedParameterCommand.ExecuteAsync(view.Parameters[0]);
+
+ Assert.Equal("b", Assert.Single(store.Document.GlobalParameters).Name);
+ }
+
+ [Fact]
+ public async Task DeleteCli_returns_to_Home()
+ {
+ Cli cli = null!;
+ var (vm, _, _, ix, _) = await BuildAsync(d =>
+ {
+ cli = new Cli { Name = "pl-app" }; // empty CLI, so the delete is allowed
+ d.Clis.Add(cli);
+ });
+ vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id);
+ _ = Assert.IsType(vm.CurrentContent);
+
+ ix.NextConfirmResult = true;
+ await vm.DeleteCurrentCliCommand.ExecuteAsync(null);
+
+ _ = Assert.IsType(vm.CurrentContent);
+ Assert.True(vm.SelectedCliChoice!.IsAll);
+ }
+
+ [Fact]
+ public async Task Home_category_is_preserved_across_a_save_refresh()
+ {
+ Cli cli = null!;
+ var (vm, _, clip, _, _) = await BuildAsync(d =>
+ {
+ cli = new Cli { Name = "pl-app" };
+ d.Clis.Add(cli);
+ d.Snips.Add(new Snip { CliId = cli.Id, Title = "Fav", CommandTemplate = "x", IsFavourite = true });
+ });
+
+ var home = Assert.IsType(vm.CurrentContent);
+ home.SelectedCategory = HomeSnipCategory.Favourites;
+ var card = Assert.Single(home.FavouriteSnips);
+
+ await vm.CopySnipCommand.ExecuteAsync(card); // copies + SaveAndRefreshAsync rebuilds Home
+
+ var refreshed = Assert.IsType(vm.CurrentContent);
+ Assert.Equal(HomeSnipCategory.Favourites, refreshed.SelectedCategory);
}
[Fact]
@@ -416,7 +572,7 @@ public async Task RestoreSnip_refreshes_the_pane_tags_for_the_selected_cli()
// Select the CLI: its pane tags should not yet include the trashed snip's tag.
vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id);
- Assert.DoesNotContain("incident", vm.Tags);
+ Assert.DoesNotContain("incident", vm.Tags.Select(t => t.Name));
// Restore from Trash while that CLI is still the selected one.
vm.OpenTrash();
@@ -424,7 +580,7 @@ public async Task RestoreSnip_refreshes_the_pane_tags_for_the_selected_cli()
await vm.RestoreSnipCommand.ExecuteAsync(card);
// The pane tag list must now reflect the restored snip, and we stay on Trash.
- Assert.Contains("incident", vm.Tags);
+ Assert.Contains("incident", vm.Tags.Select(t => t.Name));
Assert.IsType(vm.CurrentContent);
}
@@ -466,7 +622,7 @@ public async Task NewCli_adds_the_cli_and_writes_icon_bytes_when_provided()
// 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);
+ var vmWithIcons = new ShellViewModel(store, clip, clock, ix, icons, new FakeExternalLinkService());
await vmWithIcons.LoadAsync();
await vmWithIcons.NewCliCommand.ExecuteAsync(null);
diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelNavTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelNavTests.cs
new file mode 100644
index 0000000..f8bc48e
--- /dev/null
+++ b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelNavTests.cs
@@ -0,0 +1,188 @@
+using Snipdeck.Core.Abstractions;
+using Snipdeck.Core.Models;
+using Snipdeck.Core.Tests.Support;
+using Snipdeck.Core.ViewModels;
+
+namespace Snipdeck.Core.Tests.ViewModels
+{
+ public class ShellViewModelNavTests
+ {
+ private sealed class InMemorySnipStore(SnipStoreDocument document) : ISnipStore
+ {
+ public string FilePath => "in-memory";
+ public Task LoadAsync(CancellationToken ct = default) => Task.FromResult(document);
+ public Task SaveAsync(SnipStoreDocument d, CancellationToken ct = default) => Task.CompletedTask;
+ }
+
+ private static async Task<(ShellViewModel vm, FakeExternalLinkService links, Guid plId, Guid mptId)> BuildAsync()
+ {
+ var pl = new Cli { Name = "pl-app" };
+ var mpt = new Cli { Name = "mpt-app" };
+ var doc = new SnipStoreDocument
+ {
+ Clis = [pl, mpt],
+ Snips =
+ [
+ new Snip { CliId = pl.Id, Title = "Deploy", CommandTemplate = "pl deploy", Tags = ["ops"] },
+ new Snip { CliId = pl.Id, Title = "List pods", CommandTemplate = "pl pods" },
+ new Snip { CliId = mpt.Id, Title = "Deploy", CommandTemplate = "mpt deploy" },
+ ],
+ };
+ var links = new FakeExternalLinkService();
+ var vm = new ShellViewModel(
+ new InMemorySnipStore(doc),
+ new FakeClipboardService(),
+ new FakeClock(DateTimeOffset.UtcNow),
+ new FakeShellInteractions(),
+ new FakeIconAssetStorage(),
+ links);
+ await vm.LoadAsync();
+ return (vm, links, pl.Id, mpt.Id);
+ }
+
+ [Fact]
+ public async Task Initial_state_is_home_with_all_scope()
+ {
+ var (vm, _, _, _) = await BuildAsync();
+
+ Assert.True(vm.SelectedCliChoice!.IsAll);
+ Assert.Null(vm.SelectedTagItem);
+ _ = Assert.IsType(vm.CurrentContent);
+ }
+
+ [Fact]
+ public async Task Selecting_a_cli_shows_its_snips_with_the_All_tag_selected()
+ {
+ var (vm, _, plId, _) = await BuildAsync();
+
+ vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == plId);
+
+ var content = Assert.IsType(vm.CurrentContent);
+ Assert.Equal("pl-app", content.Name);
+ Assert.True(content.HasCli);
+ Assert.Equal(2, content.Snips.Count);
+ Assert.True(vm.SelectedTagItem!.IsAll);
+ }
+
+ [Fact]
+ public async Task ShowHome_returns_to_the_launcher()
+ {
+ var (vm, _, plId, _) = await BuildAsync();
+ vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == plId);
+
+ vm.ShowHome();
+
+ _ = Assert.IsType(vm.CurrentContent);
+ Assert.Null(vm.SelectedTagItem);
+ Assert.True(vm.SelectedCliChoice!.IsAll); // switcher resets to the All scope
+ }
+
+ [Fact]
+ public async Task Search_suggestions_are_scoped_and_carry_the_cli_name()
+ {
+ var (vm, _, plId, _) = await BuildAsync();
+
+ // All scope: both "Deploy" snips match, distinguished by CLI name.
+ var all = vm.GetSearchSuggestions("deploy");
+ Assert.Equal(2, all.Count);
+ Assert.Contains(all, r => r.CliName == "pl-app");
+ Assert.Contains(all, r => r.CliName == "mpt-app");
+
+ // Scoped to a CLI: only that CLI's match.
+ vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == plId);
+ var scoped = vm.GetSearchSuggestions("deploy");
+ Assert.Equal("pl-app", Assert.Single(scoped).CliName);
+ }
+
+ [Fact]
+ public async Task Selecting_a_search_result_switches_scope_and_filters_to_it()
+ {
+ var (vm, _, _, mptId) = await BuildAsync();
+
+ var result = vm.GetSearchSuggestions("deploy").Single(r => r.CliId == mptId);
+ vm.SelectSearchResult(result);
+
+ Assert.Equal(mptId, vm.SelectedCliChoice!.Cli!.Id);
+ Assert.Equal("Deploy", vm.SearchText);
+ var content = Assert.IsType(vm.CurrentContent);
+ Assert.Equal("Deploy", Assert.Single(content.Snips).Title);
+ }
+
+ [Fact]
+ public async Task Selecting_a_search_result_shows_only_that_snip_even_when_titles_collide()
+ {
+ // Two snips share a title within the same CLI; choosing one suggestion
+ // must show exactly that snip, not every title match.
+ var cli = new Cli { Name = "pl-app" };
+ var first = new Snip { CliId = cli.Id, Title = "Deploy", CommandTemplate = "pl deploy --a" };
+ var second = new Snip { CliId = cli.Id, Title = "Deploy", CommandTemplate = "pl deploy --b" };
+ var doc = new SnipStoreDocument { Clis = [cli], Snips = [first, second] };
+ var vm = new ShellViewModel(
+ new InMemorySnipStore(doc),
+ new FakeClipboardService(),
+ new FakeClock(DateTimeOffset.UtcNow),
+ new FakeShellInteractions(),
+ new FakeIconAssetStorage(),
+ new FakeExternalLinkService());
+ await vm.LoadAsync();
+
+ var result = vm.GetSearchSuggestions("deploy").Single(r => r.SnipId == second.Id);
+ vm.SelectSearchResult(result);
+
+ var content = Assert.IsType(vm.CurrentContent);
+ Assert.Equal(second.Id, Assert.Single(content.Snips).Snip.Id);
+ }
+
+ [Fact]
+ public async Task ApplySearch_from_home_moves_to_the_filtered_snip_list()
+ {
+ var (vm, _, _, _) = await BuildAsync();
+ Assert.Null(vm.SelectedTagItem); // on Home
+
+ vm.ApplySearch("deploy");
+
+ Assert.True(vm.SelectedTagItem!.IsAll); // moved onto the snip list
+ Assert.Equal("deploy", vm.SearchText);
+ var content = Assert.IsType(vm.CurrentContent);
+ Assert.Equal(2, content.Snips.Count); // both "Deploy" snips across CLIs
+ }
+
+ [Fact]
+ public async Task ApplySearch_returns_to_the_snip_list_from_a_non_snip_page()
+ {
+ var (vm, _, plId, _) = await BuildAsync();
+ vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == plId); // snip list, All tag
+ vm.OpenTrash(); // non-snip page, but SelectedTagItem stays non-null
+ _ = Assert.IsType(vm.CurrentContent);
+
+ vm.ApplySearch(string.Empty); // same (empty) query text as the default
+
+ _ = Assert.IsType(vm.CurrentContent);
+ }
+
+ [Fact]
+ public async Task SelectTag_reapplies_the_snip_list_when_the_same_tag_is_re_invoked()
+ {
+ var (vm, _, plId, _) = await BuildAsync();
+ vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == plId);
+ var allTag = vm.Tags.First(t => t.IsAll);
+ vm.OpenTrash(); // non-snip page; SelectedTagItem stays = allTag
+ _ = Assert.IsType(vm.CurrentContent);
+
+ vm.SelectTag(allTag); // re-invoking the already-selected tag must still navigate back
+
+ _ = Assert.IsType(vm.CurrentContent);
+ }
+
+ [Fact]
+ public async Task OpenDocumentation_opens_the_readme_url()
+ {
+ var (vm, links, _, _) = await BuildAsync();
+
+ await vm.OpenDocumentationAsync();
+
+ Assert.Equal(1, links.OpenCount);
+ Assert.Equal(ShellViewModel.DocumentationUrl, links.LastOpenedUrl);
+ }
+ }
+}
diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs
index 098a292..e3ed8fb 100644
--- a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs
+++ b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs
@@ -51,7 +51,8 @@ private static ShellViewModel NewShellViewModel(
clipboard ?? new FakeClipboardService(),
clock ?? new FakeClock(DateTimeOffset.UtcNow),
interactions ?? new FakeShellInteractions(),
- icons ?? new FakeIconAssetStorage());
+ icons ?? new FakeIconAssetStorage(),
+ new FakeExternalLinkService());
}
private static SnipStoreDocument SampleDocument(out Guid plAppId, out Guid mptAppId)
@@ -85,7 +86,7 @@ public async Task After_LoadAsync_home_choice_is_selected_and_content_is_a_HomeV
await vm.LoadAsync();
Assert.NotNull(vm.SelectedCliChoice);
- Assert.True(vm.SelectedCliChoice!.IsHome);
+ Assert.True(vm.SelectedCliChoice!.IsAll);
_ = Assert.IsType(vm.CurrentContent);
}
@@ -98,7 +99,7 @@ public async Task CliChoices_contains_home_followed_by_cli_choices_in_alphabetic
await vm.LoadAsync();
Assert.Equal(3, vm.CliChoices.Count);
- Assert.True(vm.CliChoices[0].IsHome);
+ Assert.True(vm.CliChoices[0].IsAll);
Assert.Equal("mpt-app", vm.CliChoices[1].Display);
Assert.Equal("pl-app", vm.CliChoices[2].Display);
}
@@ -114,25 +115,29 @@ public async Task Selecting_a_cli_swaps_content_to_a_CliViewModel_and_rebuilds_t
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);
+ var tagNames = vm.Tags.Select(t => t.Name).ToList();
+ Assert.Contains(ShellViewModel.AllTagsSentinel, tagNames);
+ Assert.Contains("read", tagNames);
+ Assert.Contains("deploy", tagNames);
+ Assert.Contains("orgs", tagNames);
Assert.Equal(ShellViewModel.AllTagsSentinel, vm.SelectedTag);
}
[Fact]
- public async Task Selecting_home_clears_tags_and_resets_to_home_content()
+ public async Task ShowHome_shows_the_launcher_and_clears_the_tag_selection()
{
var doc = SampleDocument(out var plAppId, out _);
var vm = NewShellViewModel(new InMemorySnipStore(doc));
await vm.LoadAsync();
vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == plAppId);
- vm.GoHome();
+ vm.ShowHome();
- Assert.Empty(vm.Tags);
_ = Assert.IsType(vm.CurrentContent);
+ Assert.Null(vm.SelectedTagItem); // no tag selected on Home
+ Assert.True(vm.SelectedCliChoice!.IsAll); // Home also resets the switcher to All
+ // The All-scope tag list is populated in the nav (Home is just a content destination).
+ Assert.Contains("deploy", vm.Tags.Select(t => t.Name));
}
[Fact]
diff --git a/tests/Snipdeck.Core.Tests/ViewModels/TagIconsViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/TagIconsViewModelTests.cs
new file mode 100644
index 0000000..d6ca071
--- /dev/null
+++ b/tests/Snipdeck.Core.Tests/ViewModels/TagIconsViewModelTests.cs
@@ -0,0 +1,45 @@
+using Snipdeck.Core.ViewModels;
+
+namespace Snipdeck.Core.Tests.ViewModels
+{
+ public class TagIconsViewModelTests
+ {
+ // "X" is a stand-in glyph — any non-blank, non-default string exercises the logic.
+
+ [Fact]
+ public void Rows_are_distinct_sorted_and_prefilled_with_stored_glyphs()
+ {
+ var vm = new TagIconsViewModel(
+ ["deploy", "ops", "deploy"],
+ new Dictionary { ["deploy"] = "X" });
+
+ Assert.Equal(["deploy", "ops"], vm.Rows.Select(r => r.TagName));
+ Assert.Equal("X", vm.Rows[0].Glyph); // prefilled from the stored map
+ Assert.Equal(string.Empty, vm.Rows[1].Glyph); // no stored glyph -> blank (uses the default tag icon)
+ }
+
+ [Fact]
+ public void PreviewGlyph_falls_back_to_default_when_blank_else_shows_the_glyph()
+ {
+ var row = new TagIconRowViewModel("ops", string.Empty);
+ Assert.Equal(TagItemViewModel.DefaultGlyph, row.PreviewGlyph);
+
+ row.Glyph = "X";
+ Assert.Equal("X", row.PreviewGlyph);
+ }
+
+ [Fact]
+ public void BuildTagIcons_keeps_only_non_default_glyphs()
+ {
+ var vm = new TagIconsViewModel(["a", "b", "c"], new Dictionary());
+ vm.Rows[0].Glyph = "X"; // custom -> kept
+ vm.Rows[1].Glyph = TagItemViewModel.DefaultGlyph; // explicit default -> dropped
+ vm.Rows[2].Glyph = " "; // blank -> dropped
+
+ var map = vm.BuildTagIcons();
+
+ Assert.Equal(["a"], map.Keys);
+ Assert.Equal("X", map["a"]);
+ }
+ }
+}