Skip to content

Conversation

@ToddGrun
Copy link
Contributor

@ToddGrun ToddGrun commented Nov 9, 2025

Modifies completion in several ways:

  1. Makes ':' no longer a commit character. This being a commit character was causing difficulties as it and '=' require fundamentally different completion commit behavior, and LSP doesn't support that for a single completion item.
  2. Makes parameter completions show up in attribute name contexts too. This is nice for discoverability.

Old completion behavior:
image

New completion behavior:
image

Fixes: https://devdiv.visualstudio.com/DevDiv/_workitems/edit/2607948

Review by commit might make things a bit easier

… directive attribute completion.

2) Rename some test files to make diffing easier
@ToddGrun ToddGrun requested a review from a team as a code owner November 9, 2025 01:07
@ToddGrun ToddGrun force-pushed the dev/toddgrun/BetterHandleParameterAttributeDirectiveCompletion branch from bed4546 to 5b0dc82 Compare November 9, 2025 10:00
Copy link
Member

@davidwengier davidwengier left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm misunderstanding something, but I'm beginning to wonder if we still need separate attribute name and parameter logic at all? Can't we just have the logic be "we're going to add a completion item for the attribute, and each parameter, unless they already exist"? I don't understand why there is an attribute name context and a parameter name context, if we're always just adding parameters to completion.

}

[Fact]
public void GetCompletionItems_OnDirectiveAttributeName_bind_ReturnsParameterSnippetCompletions()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks identical to the test above?

var inSnippetContext = InSnippetContext(containingAttribute, razorCompletionOptions);
using var _ = SpecializedPools.GetPooledStringDictionary<(ImmutableArray<BoundAttributeDescriptionInfo>, ImmutableArray<RazorCommitCharacter>, RazorCompletionItemKind kind)>(out var attributeCompletions);

// Collect indexer descriptors and their parent tag helper type names
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have no idea what an indexer descriptor is, and I'm guessing you didn't before you started working in this file. Assuming you've had to find out to do this PR, could you put a comment somewhere explaining (unless its already somewhere not shown in the diff)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I certainly knew nothing about them before this change. I've added a comment outlining my limited understanding and how they are used here.


foreach (var parameterDescriptor in attributeDescriptor.Parameters)
{
if (!context.ExistingAttributes.IsDefault
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (and I'm guessing you didn't write this anyway): The IsDefault check could be outside the loop, though I'm not sure its needed at all with .Any

Copy link
Contributor Author

@ToddGrun ToddGrun Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is needed. The ImmutableArray extension for Any doesn't check for the underlying array being null. Taking a closer look, there was a logic issue as this should be adding the completion if there are no attributes. Will fix that, but keep inside the loop as it fits there.

return;
}

// Add indexer parameter completions first
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to above, whats an indexer parameter?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Described in the other location, updated comment here to indicating why the ordering here is intentional

@ToddGrun
Copy link
Contributor Author

ToddGrun commented Nov 9, 2025

Maybe I'm misunderstanding something, but I'm beginning to wonder if we still need separate attribute name and parameter logic at all? Can't we just have the logic be "we're going to add a completion item for the attribute, and each parameter, unless they already exist"? I don't understand why there is an attribute name context and a parameter name context, if we're always just adding parameters to completion.

Parameter completion items display differently based on the context:

Attribute name context:
image

Parameter name context:
image

@davidwengier
Copy link
Member

Ahh okay, so we kinda have the new behaviour and the old behaviour. I definitely missed that.

Though I do wonder, is it worth the complexity for the latter screenshot? Presumably if we just returning all of the completion items in full, won't the IDE filter the list to only things that start with @bind-Value: since that is already present in the document?

Or does it not do it because : is a word break character?

@ToddGrun
Copy link
Contributor Author

Though I do wonder, is it worth the complexity for the latter screenshot?

I don't feel like much of the complexity comes from calculating the In(Attribute/Parameter)Name values or the checks on them.

I locally removed those checks, and do see some funky behavior if I Ctrl+j after a ':', as it appears only the ':' is included in the filtering

image

@davidwengier
Copy link
Member

That is funky. Okay, I rescind those comments :)

internal record DirectiveAttributeCompletionContext(
string SelectedAttributeName = "",
string? SelectedParameterName = null,
ImmutableArray<string> ExistingAttributes = default,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider changing this type so ExistingAttributes is [] instead of default when not set. Adding a little more code here will help avoid extra IsDefault checks below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm feeling kinda dense here as default params need to be constants. Was there an easier way that you were thinking of rather than the change I made in commit 13?

Copy link
Member

@davidwengier davidwengier left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my comments ended up being mostly irrelevant due to my own misunderstanding.

The rest neatly demonstrate why I prefer the more snapshot type of tests in the cohosting completion tests, but migrating all of our current completion tests over is definitely not in scope for this PR!

{
public string SelectedAttributeName { get; init; }
public string? SelectedParameterName { get; init; }
public ImmutableArray<string> ExistingAttributes { get; init; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current default is only applied if the constructor is used. In other places we have used this pattern to ensure the property is never observable default.

Suggested change
public ImmutableArray<string> ExistingAttributes { get; init; }
public ImmutableArray<string> ExistingAttributes { get; set => field = value.NullToEmpty(); } = [];

public bool InParameterName { get; init; }
public RazorCompletionOptions Options { get; init; }

public DirectiveAttributeCompletionContext(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact that all of these are optional makes me wonder if its worth having, versus just having callers use the object initializer syntax. Looks like there is only one caller of this anyway?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's nicer. Thanks!

@ToddGrun
Copy link
Contributor Author

Waiting for @sayedihashimi to give an ok after trying out the validation build.

@sayedihashimi
Copy link
Member

I'm going through it right now. I should have some info to share soon. Thus far I've found 1 issue when using @Bind=. I'll share the info here soon.

@sayedihashimi
Copy link
Member

sayedihashimi commented Nov 11, 2025

I've tried this out just now and noticed an issue with @bind. Here is what I'm seeing.

When I type @bind= when typing = it gets changed to a -. When I delete the - and type = it changes the whole expression to tabindex="".

Here is a video

2025.11.11.razor.editor.bind.issue.01.mp4

In testing this, I used copilot to generate some a sample file with a bunch of examples. Below is the file that I used to test. You'll notice a bunch of duplicated elements. I just copied what Copilot created to see what it would be like typing it out. If there are other expressions that I should test, please let me know.

@page "/bind-samples"
@using System.Globalization
@using Microsoft.AspNetCore.Components.Forms

<h1>@@bind samples</h1>
<p>Demonstrates various @@bind syntaxes on HTML and component elements.</p>

<h2>Native element bindings (@@bind)</h2>
<p>
    <label>Name (input @@bind with oninput + after)</label><br />
    <input @bind="Name" @bind:event="oninput" @bind:after="OnNameAfter" />
</p>
<p>
    <label>Age (number)</label><br />
    <input type="number" @bind="Age" />
</p>
<p>
    <label>Birthday (date with format)</label><br />
    <input type="date" @bind="Birthday" @bind:format="yyyy-MM-dd" />
    @* 
        Issue 1
        When I typed = after @bind, it changed = to a -
        After I deleted - and typed = it completed it to 'tabindex'
    *@
    <input type="date" @bind="Birthday" @bind:format="dd/MM/yyyy" />
</p>
<p>
    <label>Search (input with after callback)</label><br />
    <input @bind="Search" @bind:after="OnSearchChanged" @bind:event="oninput" />
    <input @bind="Search" @bind:after="OnSearchChanged" @bind:event="onchange" />
</p>
<p>
    <label>Explicit get/set (uppercasing)</label><br />
    <input @bind:get="() => Name" @bind:set="val => Name = val.ToUpperInvariant()" placeholder="Name uppercased on set" />
    <input @bind:get="()=> Name" @bind:set="val => Name = val.ToUpperInvariant()" placeholder="Name uppercased on set" />
</p>

<h2>Component bindings (@@bind-Value)</h2>
<p>
    <label>Age via InputNumber (@@bind-Value)</label><br />
    <InputNumber @bind-Value="Age" />
    <InputNumber @bind-Value="Age" />
</p>
<p>
    <label>Price via PriceBox (@@bind-Value:culture)</label><br />
    <PriceBox @bind-Value="Price" @bind-Value:culture="CultureInfo.GetCultureInfo(SelectedCulture)" />
    <PriceBox @bind-Value="Price" @bind-Value:culture="CultureInfo.GetCultureInfo(SelectedCulture)" />
</p>
<p>
    <label>Birthday via InputDate (@@bind-Value:format)</label><br />
    <InputDate @bind-Value="Birthday" @bind-Value:format="yyyy-MM-dd" />
    <InputDate @bind-Value="Birthday" @bind-Value:format="yyyy-MM-dd" />
</p>
<p>
    <label>Stepper (@@bind-Value with after)</label><br />
    <CustomStepper @bind-Value="StepperValue" @bind-Value:after="OnStepperAfter" Min="0" Max="10" />
    <CustomStepper @bind-Value="StepperValue" @bind-Value:after="OnStepperAfter" Min="0" Max="10" />
</p>
<p>
    <label>Stepper explicit get/set enforcing even (@@bind-Value:get/set)</label><br />
    <!-- Simplified: demonstrate after callback adjusting value to even -->
    <CustomStepper @bind-Value="StepperValue2" @bind-Value:after="OnStepper2After" Min="0" Max="10" /> Value (even): @StepperValue2
    <CustomStepper @bind-Value="StepperValue2" @bind-Value:after="OnStepper2After" Min="0" Max="10" />
</p>
<p>
    <label>DateBox with custom format (@@bind-Value:format)</label><br />
    <DateBox @bind-Value="Birthday" @bind-Value:format="dd/MM/yyyy" />
    <DateBox @bind-Value="Birthday" @bind-Value:format="dd/MM/yyyy" />
</p>
<p>
    <label>Live slider (@@bind with event & after)</label><br />
    <input type="range" min="0" max="100" @bind="RangeValue" @bind:event="oninput" @bind:after="OnRangeAfter" /> Value: @RangeValue
    <input type="range" min="0" max="100" @bind="RangeValue" @bind:event="oninput" @bind:after="OnRangeAfter" />
</p>

<h2>Select list</h2>
<p>
    <label>Culture (select @@bind)</label><br />
    <select @bind="SelectedCulture">
        @foreach (var c in Cultures)
        {
            <option value="@c">@c</option>
        }
    </select>

    <select @bind="SelectedCulture">
        
    </select>


</p>

<h2>Textarea examples</h2>
<p>
    <label>Notes (default change)</label><br />
    <textarea @bind="Notes"></textarea>
    <textarea @bind="Notes"></textarea>
</p>
<p>
    <label>Live Notes (oninput + after)</label><br />
    <textarea @bind="LiveNotes" @bind:event="oninput" @bind:after="OnLiveNotesAfter"></textarea>
    <textarea @bind="LiveNotes" @bind:event="oninput" @bind:after="OnLiveNotesAfter"></textarea>
</p>

<h2>Checkbox / bool</h2>
<p>
    <label><input type="checkbox" @bind="IsEnabled" /> Enabled</label>
    <label><input type="checkbox" @bind="IsEnabled"/> Enabled</label>
</p>

<h2>Radio group</h2>
<p>
    <label><input type="radio" name="level" value="Low" @bind="SelectedLevel" /> Low</label>
    <label><input type="radio" name="level" value="low" @bind="SelectedLevel" /> Low</label>

    <label><input type="radio" name="level" value="Medium" @bind="SelectedLevel" /> Medium</label>
    <label><input type="radio" name="level" value="Medium" @bind="SelectedLevel" /> Medium</label>


    <label><input type="radio" name="level" value="High" @bind="SelectedLevel" /> High</label>
    <label><input type="radio" name="level" value="High" @bind="SelectedLevel" /> High</label>
</p>

<h2>Summary</h2>
<ul>
    <li>Name: @Name</li>
    <li>Age: @Age</li>
    <li>Price (@SelectedCulture): @Price.ToString(CultureInfo.GetCultureInfo(SelectedCulture))</li>
    <li>Birthday: @Birthday.ToString("yyyy-MM-dd")</li>
    <li>Search: @Search</li>
    <li>Notes: @Notes</li>
    <li>Live Notes: @LiveNotes</li>
    <li>IsEnabled: @IsEnabled</li>
    <li>Selected Level: @SelectedLevel</li>
    <li>Range Value: @RangeValue</li>
    <li>Stepper Value: @StepperValue</li>
    <li>Stepper2 Value (even): @StepperValue2</li>
</ul>

@code {
    // Native bound properties
    private string Name { get; set; } = string.Empty;
    private int Age { get; set; } = 18;
    private decimal Price { get; set; } = 1234.56M;
    private DateTime Birthday { get; set; } = DateTime.Today;
    private string Search { get; set; } = string.Empty;
    private string Notes { get; set; } = string.Empty;
    private string LiveNotes { get; set; } = string.Empty;
    private bool IsEnabled { get; set; } = true;
    private string SelectedLevel { get; set; } = "Medium";
    private int RangeValue { get; set; } = 50;
    private int StepperValue { get; set; } = 3;
    private int StepperValue2 { get; set; } = 4;

    private string SelectedCulture { get; set; } = "en-US";
    private readonly string[] Cultures = ["en-US", "fr-FR", "de-DE", "ja-JP"];

    private void OnNameAfter() => Console.WriteLine($"Name changed to: {Name}");
    private void OnSearchChanged() => Console.WriteLine($"Search changed: {Search}");
    private void OnLiveNotesAfter() => Console.WriteLine($"LiveNotes changed length: {LiveNotes.Length}");
    private void OnRangeAfter() => Console.WriteLine($"RangeValue now: {RangeValue}");
    private void OnStepperAfter() => Console.WriteLine($"StepperValue changed: {StepperValue}");

    private void OnStepper2After() => StepperValue2 = StepperValue2 % 2 == 0 ? StepperValue2 : StepperValue2 + 1;
}

@ToddGrun
Copy link
Contributor Author

ToddGrun commented Nov 11, 2025

@sayedihashimi

Can you validate whether this is a regression from this change or if it does the same before this change? I'm not reproing on my machine with or without the change. From your video, you don't have the "@Bind" or the "@bind-value:*" entries showing up. Is that consistently the case, or is it a timing sort of issue?

@sayedihashimi
Copy link
Member

From your video, you don't have the "@Bind" or the "@bind-value:*" entries showing up. Is that consistently the case, or is it a timing sort of issue?

It wasn't consistent, sometimes it would appear and others not.

Can you validate whether this is a regression from this change or if it does the same before this change? I'm not reproing on my machine with or without the change.

VS2026 18.0 (public build) has similar, but different behavior. This doesn't have cohosting option available. Should I test it with a different version that has cohosting? Here is a video

2025.11.11.razor.editor.bind.issue.02.mp4

@sayedihashimi
Copy link
Member

@ToddGrun when working with a Blazor web app it's working good. I didn't run into any issues from your changes. I tried the following.

@page "/bind-samples"
@using System.Globalization
@using Microsoft.AspNetCore.Components.Forms

<h1>&#64;bind Samples Showcase</h1>
<p>Patterns demonstrated: &#64;bind, &#64;bind:event, &#64;bind:after, &#64;bind:format, &#64;bind:get, &#64;bind:set, &#64;bind-Value, &#64;bind-Value:event, &#64;bind-Value:after, &#64;bind-Value:culture, &#64;bind-Value:format, &#64;bind-Value:get, &#64;bind-Value:set, &#64;bind-Text:event.</p>

<h2>1. Basic &#64;bind</h2>
<input placeholder="Your name" @bind="Name" />
<input placeholder="Your name" @bind="Name" />

<p>Hello, @Name</p>

<h2>2. Change binding event (&commat;bind:event)</h2>
<input placeholder="Live search" @bind="Search" @bind:event="oninput" />
<input placeholder="Live search" @bind="Search" @bind:event="oninput" />


<p>Search (updates on each keystroke): @Search</p>

<h2>3. After update hook (&commat;bind:after)</h2>
<input type="number" @bind="Quantity" @bind:after="OnQuantityAfter" />
<input type="number" @bind="Quantity" @bind:after="OnQuantityAfter" />
<p>Quantity: @Quantity (after-called: @QuantityAfterCalls)</p>

<h2>4. Clamped integer via property setter (instead of &commat;bind:get/&commat;bind:set on native)</h2>
<input type="number" @bind="Clamped" />
<input type="number" @bind="Clamped" />
<p>Clamped value (0-100): @Clamped</p>

<h2>5. Date formatting (&commat;bind:format)</h2>
<input type="date" @bind="BirthDate" @bind:format="yyyy-MM-dd" />
<input type="date" @bind="BirthDate" @bind:format="yyyy-MM-dd" />

<p>Birth Date (long): @BirthDate.ToString("D")</p>

<h2>6. Decimal culture parsing (&commat;bind-Value:culture on InputNumber)</h2>
<select @bind="SelectedCultureName">
    <option value="en-US">en-US</option>
    <option value="fr-FR">fr-FR</option>
    <option value="de-DE">de-DE</option>
</select>
<select @bind="SelectedCultureName">

</select>
<p>Culture: @SelectedCultureName</p>
<InputNumber @bind-Value="LocalizedAmount" @bind-Value:culture="CultureInfo.GetCultureInfo(SelectedCultureName)" />
<p>Localized amount: @LocalizedAmount.ToString("N2", CultureInfo.GetCultureInfo(SelectedCultureName))</p>

<h2>7. Checkbox boolean binding</h2>
<input type="checkbox" @bind="IsEnabled" /> Enabled? @IsEnabled
<input type="checkbox" @bind="IsEnabled" /> Enabled? @IsEnabled

<h2>8. Select binding to enum</h2>
<select @bind="SelectedShade">
    @foreach(var shade in Enum.GetValues<Shade>())
    {
        <option value="@shade">@shade</option>
    }
</select>

<select @bind="SelectedShade">

</select>

<p>Selected shade: @SelectedShade</p>

<h2>9. Textarea with oninput (&commat;bind:event)</h2>
<textarea rows="3" @bind="Notes" @bind:event="oninput"></textarea>
<textarea rows="3" @bind="Notes" @bind:event="oninput"></textarea>

<p>Notes length: @(Notes?.Length ?? 0)</p>

<h2>10. Custom component value parameter (bind-Value + :after)</h2>
<BindEcho @bind-Value="EchoText" @bind-Value:after="OnEchoAfter" />
<BindEcho @bind-Value="EchoText" @bind-Value:after="OnEchoAfter" />

<p>Echo text in parent: @EchoText</p>

<h2>11. Custom component non-Value parameter (bind-Text + :event)</h2>
<FancyText @bind-Text="FancyContent" @bind-Text:event="oninput" />
<FancyText @bind-Text="FancyContent" @bind-Text:event="oninput" />

<p>Fancy content in parent: @FancyContent</p>

<h2>12. Multiple elements bound to same value</h2>
<input @bind="Shared" placeholder="Shared 1" />
<input @bind="Shared" placeholder="Shared 1" />

<input @bind="Shared" placeholder="Shared 2" />

<p>Shared: @Shared</p>

<h2>13. Built-in InputText (bind-Value:event)</h2>
<InputText @bind-Value="FrameworkText" @bind-Value:event="oninput" />
<InputText @bind-Value="FrameworkText" @bind-Value:event="oninput" />

<p>Framework text: @FrameworkText</p>

<h2>14. Nested model properties</h2>
<input @bind="PersonModel.FirstName" placeholder="First" />
<input @bind="PersonModel.LastName" placeholder="Last" />
<p>Full name: @PersonModel.FirstName @PersonModel.LastName</p>

<h2>15. Component getter/setter (bind-Value:get/bind-Value:set)</h2>
<PercentBox @bind-Value:get="_percent" @bind-Value:set="v => SetPercent(v)" />
<PercentBox @bind-Value:get="_percent" @bind-Value:set="v => SetPercent(v)" />
<p>Percent (0-100): @_percent</p>

<h2>Messages log</h2>
<ul>
    @foreach (var m in Messages)
    {
        <li>@m</li>
    }
</ul>

@code {
    private string? Name;
    private string? Search;

    private int Quantity;
    private int QuantityAfterCalls;
    private void OnQuantityAfter() { QuantityAfterCalls++; Messages.Add($"Quantity changed to {Quantity}"); }

    // Clamped via property
    private int _clamped;
    private int Clamped
    {
        get => _clamped;
        set => SetClamped(value);
    }
    private void SetClamped(int value)
    {
        var clamped = Math.Clamp(value, 0, 100);
        if (clamped != _clamped)
        {
            _clamped = clamped;
            Messages.Add($"Clamped now {_clamped}");
        }
    }

    private DateTime BirthDate = DateTime.Today;

    private string SelectedCultureName = "en-US";
    private decimal LocalizedAmount = 12345.6789m;

    private bool IsEnabled = true;

    private Shade SelectedShade = Shade.Light;
    private enum Shade { Light, Medium, Dark }

    private string? Notes;

    private string? EchoText = "Hello";
    private void OnEchoAfter() => Messages.Add($"Echo changed to '{EchoText}'");

    private string? FancyContent = "Fancy";

    private string? Shared;

    private string? FrameworkText;

    private Person PersonModel = new();
    private class Person { public string? FirstName { get; set; } public string? LastName { get; set; } }

    private int _percent = 50;
    private void SetPercent(int value) => _percent = Math.Clamp(value, 0, 100);

    private List<string> Messages = new();
}

@ToddGrun ToddGrun merged commit 4746f4e into dotnet:main Nov 12, 2025
11 checks passed
@dotnet-policy-service dotnet-policy-service bot added this to the Next milestone Nov 12, 2025
DustinCampbell added a commit that referenced this pull request Nov 24, 2025
| [Prelude](#12503) | [Part
1](#12504) | Part 2 | [Part
3](#12506) | [Part
4](#12507) | [Part
5](#12509) |

> [!WARNING]
> This pull request contains breaking changes for the RazorSdk. Once
this is merged and flows to the VMR,
dotnet/dotnet@9a7e708
will need to be cherry-picked to resolve build breaks in
`src/sdk/src/RazorSdk`.

These commits represent the (mostly) mechanical changes needed to
integrate `TagHelperCollection` across the Razor code base. Most
references to `ImmutableArrary<TagHelperDescriptor>`,
`IReadOnlyList<TagHelperDescriptor>`,
`IEnumerable<TagHelperDescriptor>`, and `TagHelperDescriptor[]` have
been replaced with `TagHelperCollection`. This is **by far** the largest
of the `TagHelperCollection` pull requests.

While most of the commits contain mechanical changes across the code
base, there are few that include more substantial work that require a
bit more scrutiny:

- **Update `RenameService` to remove
`ImmutableArray<TagHelperDescriptor>`**
(fa3ad2b)
This includes a fair amount of refactoring in `RenameService` to fix
bugs found when moving to `TagHelperCollection`.

- **Update `TagHelperFacts` to use `TagHelperCollection`**
Extra work was done in `DirectiveAttributeComplationItemProvider` to
clean up a bit following #12473.

----
CI Build:
https://dev.azure.com/dnceng/internal/_build/results?buildId=2842165&view=results
Toolset Run:
https://dev.azure.com/dnceng/internal/_build/results?buildId=2842237&view=results
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants