From e455eb7f76b921ae37d97dbd40558ae21d6c1f4a Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Sat, 30 May 2026 08:15:28 +0000 Subject: [PATCH 1/2] Render snip descriptions as Markdown in the copy flyout Snip descriptions were stored as plain text and never shown on the use-path. Parse them as Markdown in Core (MarkdownParser, backed by Markdig) into a UI-free block/inline model, and render that model to native WinUI inlines via a MarkdownPresenter control in the copy flyout. Supports headings, bold/ italic, inline and block code, links, and ordered/unordered lists. The parsing lives in Core so it's unit-tested without any UI dependency; the App only maps the model onto WinUI primitives. A snip with a description but no parameters now opens the flyout too, so its description is always visible before copying. The editor labels the field as Markdown. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 7 + Directory.Packages.props | 1 + .../Controls/MarkdownPresenter.cs | 199 ++++++++++++++++++ .../Views/ParameterFillDialog.xaml | 5 + src/Snipdeck.App/Views/SnipEditorDialog.xaml | 3 +- .../Services/Markdown/MarkdownModel.cs | 37 ++++ .../Services/Markdown/MarkdownParser.cs | 190 +++++++++++++++++ src/Snipdeck.Core/Snipdeck.Core.csproj | 1 + .../ViewModels/ParameterFillViewModel.cs | 4 + .../ViewModels/ShellViewModel.cs | 6 +- .../Services/MarkdownParserTests.cs | 98 +++++++++ .../ViewModels/ShellViewModelCommandsTests.cs | 29 +++ 12 files changed, 578 insertions(+), 2 deletions(-) create mode 100644 src/Snipdeck.App/Controls/MarkdownPresenter.cs create mode 100644 src/Snipdeck.Core/Services/Markdown/MarkdownModel.cs create mode 100644 src/Snipdeck.Core/Services/Markdown/MarkdownParser.cs create mode 100644 tests/Snipdeck.Core.Tests/Services/MarkdownParserTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c960a5..74a6f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Markdown rendering for snip descriptions.** A snip's description is now + rendered as Markdown (headings, bold/italic, inline and block code, links, + ordered/unordered lists) in the copy flyout, instead of being hidden. The + Markdown is parsed in Core (`MarkdownParser`, backed by Markdig) into a + UI-free model that the WinUI head maps onto native text; the editor labels + the field as Markdown. A snip with a description but no parameters now opens + the flyout too, so its description is always visible before copying. - **Git-derived version numbers (Nerdbank.GitVersioning).** Every build now stamps a real version (`0.1.0-alpha.+`) into all assemblies from a single `version.json`, so the About page shows a meaningful diff --git a/Directory.Packages.props b/Directory.Packages.props index 1f149da..d0e5d0f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,7 @@ + diff --git a/src/Snipdeck.App/Controls/MarkdownPresenter.cs b/src/Snipdeck.App/Controls/MarkdownPresenter.cs new file mode 100644 index 0000000..ee49632 --- /dev/null +++ b/src/Snipdeck.App/Controls/MarkdownPresenter.cs @@ -0,0 +1,199 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Documents; +using Microsoft.UI.Xaml.Media; + +using Snipdeck.Core.Services.Markdown; + +namespace Snipdeck.App.Controls +{ + /// + /// Renders a markdown string into WinUI text elements. The parsing lives in + /// Core (); this control only maps the resulting + /// UI-free model onto WinUI inlines and blocks. It builds itself as a vertical + /// stack, so no control template is needed. + /// + public sealed partial class MarkdownPresenter : StackPanel + { + private static readonly FontFamily _codeFont = new("Cascadia Mono, Consolas, Courier New"); + + public static readonly DependencyProperty MarkdownProperty = + DependencyProperty.Register( + nameof(Markdown), + typeof(string), + typeof(MarkdownPresenter), + new PropertyMetadata(null, OnMarkdownChanged)); + + public MarkdownPresenter() + { + Spacing = 8; + } + + public string? Markdown + { + get => (string?)GetValue(MarkdownProperty); + set => SetValue(MarkdownProperty, value); + } + + private static void OnMarkdownChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((MarkdownPresenter)d).Rebuild(); + } + + private void Rebuild() + { + Children.Clear(); + foreach (var block in MarkdownParser.Parse(Markdown)) + { + var element = BuildBlock(block); + if (element is not null) + { + Children.Add(element); + } + } + } + + private static FrameworkElement? BuildBlock(MarkdownBlock block) + { + switch (block) + { + case HeadingBlock heading: + { + var text = NewTextBlock(heading.Level <= 2 ? "SubtitleTextBlockStyle" : "BodyStrongTextBlockStyle"); + AddInlines(text.Inlines, heading.Inlines); + return text; + } + case ParagraphBlock paragraph: + { + var text = NewTextBlock(styleKey: null); + AddInlines(text.Inlines, paragraph.Inlines); + return text; + } + case CodeBlock code: + return BuildCodeBlock(code.Text); + case ListBlock list: + return BuildList(list); + default: + return null; + } + } + + private static TextBlock NewTextBlock(string? styleKey) + { + var text = new TextBlock + { + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = true, + }; + if (styleKey is not null + && Application.Current.Resources.TryGetValue(styleKey, out var style) + && style is Style typed) + { + text.Style = typed; + } + return text; + } + + private static Border BuildCodeBlock(string code) + { + var text = new TextBlock + { + Text = code, + FontFamily = _codeFont, + FontSize = 12, + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = true, + }; + var border = new Border + { + CornerRadius = new CornerRadius(4), + Padding = new Thickness(8, 6, 8, 6), + Child = text, + }; + if (Application.Current.Resources.TryGetValue("SubtleFillColorSecondaryBrush", out var brush) + && brush is Brush typed) + { + border.Background = typed; + } + return border; + } + + private static StackPanel BuildList(ListBlock list) + { + var container = new StackPanel { Spacing = 4 }; + for (var index = 0; index < list.Items.Count; index++) + { + var marker = list.Ordered ? $"{index + 1}." : "•"; + var row = new Grid(); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var markerText = new TextBlock { Text = marker, Margin = new Thickness(0, 0, 8, 0) }; + Grid.SetColumn(markerText, 0); + + var itemText = new TextBlock { TextWrapping = TextWrapping.Wrap, IsTextSelectionEnabled = true }; + AddInlines(itemText.Inlines, list.Items[index].Inlines); + Grid.SetColumn(itemText, 1); + + row.Children.Add(markerText); + row.Children.Add(itemText); + container.Children.Add(row); + } + return container; + } + + private static void AddInlines(InlineCollection target, IReadOnlyList inlines) + { + foreach (var inline in inlines) + { + switch (inline) + { + case TextRun run: + target.Add(BuildRun(run.Text, run.Bold, run.Italic, run.Code)); + break; + case LinkRun link: + target.Add(BuildLink(link)); + break; + case LineBreakRun: + target.Add(new LineBreak()); + break; + default: + break; + } + } + } + + private static Run BuildRun(string text, bool bold, bool italic, bool code) + { + var run = new Run { Text = text }; + if (bold) + { + run.FontWeight = Microsoft.UI.Text.FontWeights.Bold; + } + if (italic) + { + run.FontStyle = Windows.UI.Text.FontStyle.Italic; + } + if (code) + { + run.FontFamily = _codeFont; + } + return run; + } + + private static Inline BuildLink(LinkRun link) + { + var inner = BuildRun(link.Text, link.Bold, link.Italic, code: false); + // Only wrap in a Hyperlink when the URL is a usable absolute URI; + // otherwise fall back to plain text so a malformed link can't break + // rendering. + if (Uri.TryCreate(link.Url, UriKind.Absolute, out var uri)) + { + var hyperlink = new Hyperlink { NavigateUri = uri }; + hyperlink.Inlines.Add(inner); + return hyperlink; + } + return inner; + } + } +} diff --git a/src/Snipdeck.App/Views/ParameterFillDialog.xaml b/src/Snipdeck.App/Views/ParameterFillDialog.xaml index 2228a95..f83ce6e 100644 --- a/src/Snipdeck.App/Views/ParameterFillDialog.xaml +++ b/src/Snipdeck.App/Views/ParameterFillDialog.xaml @@ -3,6 +3,7 @@ x:Class="Snipdeck.App.Views.ParameterFillDialog" 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" @@ -18,6 +19,10 @@ + + diff --git a/src/Snipdeck.App/Views/SnipEditorDialog.xaml b/src/Snipdeck.App/Views/SnipEditorDialog.xaml index 1080141..35d647d 100644 --- a/src/Snipdeck.App/Views/SnipEditorDialog.xaml +++ b/src/Snipdeck.App/Views/SnipEditorDialog.xaml @@ -24,7 +24,8 @@ AcceptsReturn="True" TextWrapping="Wrap" /> - + /// A small, UI-free representation of rendered markdown. The Core parser + /// produces this model from markdown source; a UI head maps it onto its own + /// native text primitives (WinUI inlines, etc.). Keeping the model here lets + /// the parsing be unit-tested without any UI dependency. + /// + public abstract record MarkdownBlock; + + /// A run of body text made up of styled inline spans. + public sealed record ParagraphBlock(IReadOnlyList Inlines) : MarkdownBlock; + + /// A heading ( 1-6) made up of inline spans. + public sealed record HeadingBlock(int Level, IReadOnlyList Inlines) : MarkdownBlock; + + /// A fenced or indented code block, rendered verbatim in a monospace box. + public sealed record CodeBlock(string Text) : MarkdownBlock; + + /// An ordered or unordered list. Items are flattened to inline content. + public sealed record ListBlock(bool Ordered, IReadOnlyList Items) : MarkdownBlock; + + /// A single list item's inline content. + public sealed record ListItem(IReadOnlyList Inlines); + + /// An inline span within a block. + public abstract record MarkdownInline; + + /// A span of text carrying emphasis flags. marks inline code. + public sealed record TextRun(string Text, bool Bold = false, bool Italic = false, bool Code = false) : MarkdownInline; + + /// A hyperlink span. + public sealed record LinkRun(string Text, string Url, bool Bold = false, bool Italic = false) : MarkdownInline; + + /// A hard line break within a paragraph. + public sealed record LineBreakRun : MarkdownInline; +} diff --git a/src/Snipdeck.Core/Services/Markdown/MarkdownParser.cs b/src/Snipdeck.Core/Services/Markdown/MarkdownParser.cs new file mode 100644 index 0000000..9f8f382 --- /dev/null +++ b/src/Snipdeck.Core/Services/Markdown/MarkdownParser.cs @@ -0,0 +1,190 @@ +using System.Text; + +using Markdig; + +using MdInlines = Markdig.Syntax.Inlines; +using MdSyntax = Markdig.Syntax; + +namespace Snipdeck.Core.Services.Markdown +{ + /// + /// Parses markdown source into the UI-free model + /// using Markdig. A plain pipeline (no advanced extensions) keeps the output + /// predictable: paragraphs, headings, emphasis, inline/block code, links and + /// simple lists — the subset relevant to short snip descriptions. + /// + public static class MarkdownParser + { + private static readonly MarkdownPipeline _pipeline = new MarkdownPipelineBuilder().Build(); + + public static IReadOnlyList Parse(string? markdown) + { + if (string.IsNullOrWhiteSpace(markdown)) + { + return []; + } + + var document = Markdig.Markdown.Parse(markdown, _pipeline); + var blocks = new List(); + foreach (var block in document) + { + AppendBlock(block, blocks); + } + return blocks; + } + + private static void AppendBlock(MdSyntax.Block block, List output) + { + switch (block) + { + case MdSyntax.HeadingBlock heading: + output.Add(new HeadingBlock(Math.Clamp(heading.Level, 1, 6), ParseInlines(heading.Inline))); + break; + case MdSyntax.ParagraphBlock paragraph: + output.Add(new ParagraphBlock(ParseInlines(paragraph.Inline))); + break; + // FencedCodeBlock derives from CodeBlock, so it must be matched first. + case MdSyntax.FencedCodeBlock fenced: + output.Add(new CodeBlock(fenced.Lines.ToString().TrimEnd('\r', '\n'))); + break; + case MdSyntax.CodeBlock code: + output.Add(new CodeBlock(code.Lines.ToString().TrimEnd('\r', '\n'))); + break; + case MdSyntax.ListBlock list: + { + var items = new List(); + foreach (var child in list) + { + if (child is MdSyntax.ListItemBlock listItem) + { + items.Add(new ListItem(ParseListItemInlines(listItem))); + } + } + output.Add(new ListBlock(list.IsOrdered, items)); + break; + } + // QuoteBlock and any other container: flatten its children. Both + // derive from ContainerBlock, so this arm must come last. + case MdSyntax.ContainerBlock container: + foreach (var child in container) + { + AppendBlock(child, output); + } + break; + default: + // Thematic breaks, raw HTML, etc. have no place in a snip + // description render — drop them silently. + break; + } + } + + private static List ParseListItemInlines(MdSyntax.ListItemBlock listItem) + { + var inlines = new List(); + foreach (var child in listItem) + { + if (child is MdSyntax.ParagraphBlock paragraph) + { + if (inlines.Count > 0) + { + inlines.Add(new LineBreakRun()); + } + inlines.AddRange(ParseInlines(paragraph.Inline)); + } + } + return inlines; + } + + private static List ParseInlines(MdInlines.ContainerInline? container) + { + var result = new List(); + if (container is not null) + { + WalkInlines(container, bold: false, italic: false, result); + } + return result; + } + + private static void WalkInlines(MdInlines.ContainerInline container, bool bold, bool italic, List output) + { + foreach (var inline in container) + { + switch (inline) + { + case MdInlines.LiteralInline literal: + { + var text = literal.Content.ToString(); + if (text.Length > 0) + { + output.Add(new TextRun(text, bold, italic)); + } + break; + } + case MdInlines.CodeInline code: + output.Add(new TextRun(code.Content, bold, italic, Code: true)); + break; + case MdInlines.EmphasisInline emphasis: + { + // One delimiter (*x* / _x_) is italic; two (**x**) is bold. + var nestedBold = emphasis.DelimiterCount >= 2 || bold; + var nestedItalic = emphasis.DelimiterCount == 1 || italic; + WalkInlines(emphasis, nestedBold, nestedItalic, output); + break; + } + case MdInlines.LinkInline { IsImage: false } link: + output.Add(new LinkRun(CollectText(link), link.Url ?? string.Empty, bold, italic)); + break; + case MdInlines.LinkInline image: + { + // No image rendering — fall back to the alt text. + var alt = CollectText(image); + if (alt.Length > 0) + { + output.Add(new TextRun(alt, bold, italic)); + } + break; + } + case MdInlines.LineBreakInline lineBreak: + // A hard break is an explicit newline; a soft break (a plain + // newline in the source) just keeps words separated. + output.Add(lineBreak.IsHard ? new LineBreakRun() : new TextRun(" ", bold, italic)); + break; + case MdInlines.ContainerInline nested: + WalkInlines(nested, bold, italic, output); + break; + default: + // Autolinks, raw inline HTML, etc. — ignored for this subset. + break; + } + } + } + + private static string CollectText(MdInlines.ContainerInline container) + { + var builder = new StringBuilder(); + CollectText(container, builder); + return builder.ToString(); + } + + private static void CollectText(MdInlines.ContainerInline container, StringBuilder builder) + { + foreach (var inline in container) + { + switch (inline) + { + case MdInlines.LiteralInline literal: + _ = builder.Append(literal.Content.ToString()); + break; + case MdInlines.CodeInline code: + _ = builder.Append(code.Content); + break; + case MdInlines.ContainerInline nested: + CollectText(nested, builder); + break; + default: + break; + } + } + } + } +} diff --git a/src/Snipdeck.Core/Snipdeck.Core.csproj b/src/Snipdeck.Core/Snipdeck.Core.csproj index 9df329e..1d93b35 100644 --- a/src/Snipdeck.Core/Snipdeck.Core.csproj +++ b/src/Snipdeck.Core/Snipdeck.Core.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs b/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs index d30ff12..c770bda 100644 --- a/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs @@ -29,6 +29,10 @@ public ParameterFillViewModel(Snip snip) public Snip Snip { get; } + public string? Description => Snip.Description; + + public bool HasDescription => !string.IsNullOrWhiteSpace(Snip.Description); + public ObservableCollection Inputs { get; } [ObservableProperty] diff --git a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs index 20674bb..0e986b6 100644 --- a/src/Snipdeck.Core/ViewModels/ShellViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/ShellViewModel.cs @@ -100,7 +100,11 @@ private async Task CopySnipAsync(SnipCardViewModel? cardVm) var snip = cardVm.Snip; string commandToCopy; - if (snip.Parameters.Count == 0) + // Skip the flyout only when there's nothing to show: no parameters to + // fill and no description to read. A described-but-parameterless snip + // still opens the flyout so its (rendered) description is visible + // before the copy. + if (snip.Parameters.Count == 0 && string.IsNullOrWhiteSpace(snip.Description)) { commandToCopy = snip.CommandTemplate; } diff --git a/tests/Snipdeck.Core.Tests/Services/MarkdownParserTests.cs b/tests/Snipdeck.Core.Tests/Services/MarkdownParserTests.cs new file mode 100644 index 0000000..0e51fa5 --- /dev/null +++ b/tests/Snipdeck.Core.Tests/Services/MarkdownParserTests.cs @@ -0,0 +1,98 @@ +using Snipdeck.Core.Services.Markdown; + +namespace Snipdeck.Core.Tests.Services +{ + public class MarkdownParserTests + { + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Blank_input_yields_no_blocks(string? input) + { + Assert.Empty(MarkdownParser.Parse(input)); + } + + [Fact] + public void Plain_paragraph_becomes_a_single_text_run() + { + var blocks = MarkdownParser.Parse("Just some text."); + + var paragraph = Assert.IsType(Assert.Single(blocks)); + var run = Assert.IsType(Assert.Single(paragraph.Inlines)); + Assert.Equal("Just some text.", run.Text); + Assert.False(run.Bold); + Assert.False(run.Italic); + Assert.False(run.Code); + } + + [Fact] + public void Bold_italic_and_inline_code_set_the_run_flags() + { + var blocks = MarkdownParser.Parse("**b** *i* `c`"); + var paragraph = Assert.IsType(Assert.Single(blocks)); + + var bold = Assert.IsType(paragraph.Inlines[0]); + Assert.Equal("b", bold.Text); + Assert.True(bold.Bold); + + var italic = paragraph.Inlines.OfType().Single(r => r.Text == "i"); + Assert.True(italic.Italic); + + var code = paragraph.Inlines.OfType().Single(r => r.Text == "c"); + Assert.True(code.Code); + } + + [Fact] + public void Heading_captures_level_and_text() + { + var heading = Assert.IsType(Assert.Single(MarkdownParser.Parse("### Usage"))); + Assert.Equal(3, heading.Level); + Assert.Equal("Usage", Assert.IsType(Assert.Single(heading.Inlines)).Text); + } + + [Fact] + public void Fenced_code_block_is_captured_verbatim() + { + var blocks = MarkdownParser.Parse("```\npl-app deploy --env prod\n```"); + + var code = Assert.IsType(Assert.Single(blocks)); + Assert.Equal("pl-app deploy --env prod", code.Text); + } + + [Fact] + public void Unordered_list_yields_items() + { + var list = Assert.IsType(Assert.Single(MarkdownParser.Parse("- first\n- second"))); + Assert.False(list.Ordered); + Assert.Equal(2, list.Items.Count); + Assert.Equal("first", Assert.IsType(Assert.Single(list.Items[0].Inlines)).Text); + Assert.Equal("second", Assert.IsType(Assert.Single(list.Items[1].Inlines)).Text); + } + + [Fact] + public void Ordered_list_is_flagged_ordered() + { + var list = Assert.IsType(Assert.Single(MarkdownParser.Parse("1. one\n2. two"))); + Assert.True(list.Ordered); + Assert.Equal(2, list.Items.Count); + } + + [Fact] + public void Link_captures_text_and_url() + { + var paragraph = Assert.IsType(Assert.Single(MarkdownParser.Parse("See [the docs](https://example.com/x)."))); + var link = paragraph.Inlines.OfType().Single(); + Assert.Equal("the docs", link.Text); + Assert.Equal("https://example.com/x", link.Url); + } + + [Fact] + public void Hard_line_break_becomes_a_line_break_run() + { + // Two trailing spaces before the newline is a markdown hard break. + var paragraph = Assert.IsType(Assert.Single(MarkdownParser.Parse("line one \nline two"))); + Assert.Contains(paragraph.Inlines, i => i is LineBreakRun); + } + } +} diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs index 545a451..f7c9b1d 100644 --- a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs +++ b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelCommandsTests.cs @@ -65,6 +65,35 @@ public async Task CopySnip_with_no_parameters_writes_clipboard_directly_and_bump Assert.Equal(clock.UtcNow, store.Document.Snips[0].LastUsedAt); } + [Fact] + public async Task CopySnip_opens_the_flyout_for_a_described_snip_even_without_parameters() + { + Cli cli = null!; + var (vm, _, clip, ix, _) = await BuildAsync(d => + { + cli = new Cli { Name = "pl-app" }; + d.Clis.Add(cli); + // No parameters, but a description worth reading before copy. + d.Snips.Add(new Snip + { + CliId = cli.Id, + Title = "Status", + CommandTemplate = "pl-app status", + Description = "Shows **cluster** status.", + }); + }); + + ix.NextParameterFillResult = new ParameterFillResult("pl-app status"); + vm.SelectedCliChoice = vm.CliChoices.Single(c => c.Cli?.Id == cli.Id); + var card = ((CliViewModel)vm.CurrentContent!).Snips[0]; + + await vm.CopySnipCommand.ExecuteAsync(card); + + // The flyout was shown (so the description renders) rather than copying directly. + Assert.NotNull(ix.LastFilledSnip); + Assert.Equal("pl-app status", clip.LastText); + } + [Fact] public async Task CopySnip_with_parameters_uses_resolved_command_from_interactions() { From e0a16619e7f29f765c1c22589f0939751d29d21f Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Sat, 30 May 2026 08:21:46 +0000 Subject: [PATCH 2/2] Keep copy enabled for parameterless snips with token-like template text Routing described-but-parameterless snips through the fill flyout regressed copy for templates containing bare {token} text: with no declared parameters the engine left the token unresolved and IsCopyEnabled stayed false, so the snip could no longer be copied (the direct path had copied it verbatim). Gate copy on full resolution only when there are parameters to fill; with none, the template copies verbatim as before. Capture the parameter presence in a field since a defaulted input fires its change callback during construction, before Inputs is assigned. Found by codex review. Co-Authored-By: Claude Opus 4.8 --- .../ViewModels/ParameterFillViewModel.cs | 12 +++++++++++- .../ViewModels/ParameterFillViewModelTests.cs | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs b/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs index c770bda..c522a8d 100644 --- a/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/ParameterFillViewModel.cs @@ -10,12 +10,17 @@ namespace Snipdeck.Core.ViewModels public sealed partial class ParameterFillViewModel : ObservableObject { private readonly Dictionary _values = new(StringComparer.Ordinal); + private readonly bool _hasParameters; public ParameterFillViewModel(Snip snip) { ArgumentNullException.ThrowIfNull(snip); Snip = snip; + // Captured before building Inputs: a parameter with a default fires its + // change callback (→ UpdateResolution) during construction, before the + // Inputs collection is assigned, so UpdateResolution can't read Inputs. + _hasParameters = snip.Parameters.Count > 0; Inputs = new ObservableCollection( snip.Parameters.Select(p => new ParameterInputViewModel(p, OnInputValueChanged))); @@ -53,7 +58,12 @@ private void UpdateResolution() { var result = SubstitutionEngine.Substitute(Snip.CommandTemplate, _values); Preview = result.Text; - IsCopyEnabled = result.IsFullyResolved; + // With no parameters to fill there's nothing to gate on: the template + // copies verbatim (unresolved tokens are returned as-is), matching the + // direct, no-dialog copy path. This is the case for a snip opened in + // the flyout solely to show its description. Only gate copy on full + // resolution when there are actually inputs to fill. + IsCopyEnabled = !_hasParameters || result.IsFullyResolved; } } } diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs index 6a98463..9601d32 100644 --- a/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs +++ b/tests/Snipdeck.Core.Tests/ViewModels/ParameterFillViewModelTests.cs @@ -17,6 +17,22 @@ public void Snip_with_no_parameters_is_immediately_copy_enabled_with_template_as Assert.Empty(vm.Inputs); } + [Fact] + public void Parameterless_snip_with_token_like_template_text_stays_copy_enabled() + { + // No declared parameters, but the template contains a bare {token} that + // the engine treats as an unresolved placeholder. There's nothing to + // fill, so it must copy verbatim (as the direct copy path does) rather + // than being gated off as "unresolved". + var snip = new Snip { CommandTemplate = "git commit -m {message}" }; + + var vm = new ParameterFillViewModel(snip); + + Assert.Empty(vm.Inputs); + Assert.True(vm.IsCopyEnabled); + Assert.Equal("git commit -m {message}", vm.Preview); + } + [Fact] public void Defaults_pre_fill_inputs_and_drive_copy_enabled_state() {