Fold gap-fix findings into adapters: prop-change bridge, fallback ID, status context, toast validation, content refresh

Consolidates 6 gap-fix branches' C# adapter/proxy changes into phase-3
so the JS/TS stack stays at 7 PRs instead of growing to 15:

- JSObservableProxyBase + IJSPropertyChangeTarget + JSPropertyChangeRegistry:
  shared property-change notification plumbing for JS-backed adapters
- JSContentPageProxy: content refresh via itemsChanged, layered on the
  new base class
- JSFallbackCommandItemAdapter, JSInvokableCommandAdapter,
  JSCommandProviderProxy, JSCommandResultParser: fallback command IDs,
  status context propagation, toast payload validation fixes
- JSStatusNotificationTests, JSAdapterProxyTests, JSAdapterRemediationTests:
  covering tests

Reconciled JSListPageProxy.cs by hand: the combined gap-fix branch was
cut from a tip that already included phase-4's ComputeKey/UpdateData
item-cache reuse logic, which isn't part of any gap fix and doesn't
exist yet on phase-3. Restored phase-3's original simple item
construction instead of carrying that unrelated phase-4 code forward.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0fb6a401-cb0b-4f9b-b721-19987117e9e0
This commit is contained in:
Michael Jolley
2026-08-14 15:03:25 -05:00
committed by Michael Jolley
parent 72807b5916
commit e6d553b888
12 changed files with 761 additions and 66 deletions

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
internal interface IJSPropertyChangeTarget
{
void ApplyPropertyChanges(JsonElement properties);
}

View File

@@ -21,7 +21,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// provider calls over JSON-RPC. Fallback display titles, host status messages,
/// log messages and clipboard requests raised by the extension are handled here.
/// </summary>
public sealed partial class JSCommandProviderProxy : ICommandProvider, IDisposable
public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposable
{
private readonly JsonRpcConnection _connection;
private readonly JSExtensionManifest _manifest;
@@ -200,6 +200,39 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider, IDisposab
}
}
public ICommandItem? GetCommandItem(string id)
{
try
{
var response = _connection.SendRequestAsync(
"provider/getCommandItem",
new JsonObject { ["commandId"] = id },
CancellationToken.None).GetAwaiter().GetResult();
if (response.Error != null)
{
Logger.LogWarning($"GetCommandItem error for {id}: {response.Error.Message}");
return null;
}
if (!response.Result.HasValue || response.Result.Value.ValueKind != JsonValueKind.Object)
{
return null;
}
return new JSCommandItemAdapter(response.Result.Value, _connection);
}
catch (Exception ex)
{
Logger.LogWarning($"Failed to get command item {id}: {ex.Message}");
return null;
}
}
public object[] GetApiExtensionStubs() => [];
public ICommandItem[]? GetDockBands() => null;
public void InitializeWithHost(IExtensionHost host)
{
ArgumentNullException.ThrowIfNull(host);
@@ -369,6 +402,8 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider, IDisposab
try
{
JSPropertyChangeRegistry.Dispatch(_connection, paramsElement);
var commandId = JSModelMapper.GetString(paramsElement, "commandId") ?? string.Empty;
if (string.IsNullOrEmpty(commandId) ||
!_fallbackAdapters.TryGetValue(commandId, out var fallbackAdapter))
@@ -716,9 +751,14 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider, IDisposab
return (StatusContext)contextProp.GetInt32();
}
if (contextProp.ValueKind == JsonValueKind.String && contextProp.GetString() == "page")
if (contextProp.ValueKind == JsonValueKind.String)
{
return StatusContext.Page;
return contextProp.GetString() switch
{
"page" => StatusContext.Page,
"extension" => StatusContext.Extension,
_ => StatusContext.Extension,
};
}
}

View File

@@ -94,6 +94,23 @@ internal static class JSCommandResultParser
var toastArgs = new ToastArgs { Message = message };
if (args.ValueKind == JsonValueKind.Object &&
JSModelMapper.TryGetAnyCase(args, "icon", "Icon", out var iconProp))
{
toastArgs.Icon = JSModelMapper.ParseIconInfo(iconProp);
}
// Action commands require the live connection used by the command
// adapter. If parsing is used without one, keep the toast usable and
// omit only the unavailable action.
if (connection != null &&
args.ValueKind == JsonValueKind.Object &&
JSModelMapper.TryGetAnyCase(args, "command", "Command", out var commandProp) &&
commandProp.ValueKind == JsonValueKind.Object)
{
toastArgs.Command = JSCommandFactory.CreateCommandFromJson(commandProp, connection);
}
// A toast can carry a nested continuation result that the shell executes
// after the toast is shown. Parse it recursively so every nested kind
// (including confirm, which needs the connection for its primary command,

View File

@@ -3,9 +3,13 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels.Services.JsonRpc;
using Microsoft.CommandPalette.Extensions;
@@ -19,46 +23,51 @@ namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// Content is fetched with <c>contentPage/getContent</c>; details and commands are
/// materialized from the page payload.
/// </summary>
internal sealed partial class JSContentPageProxy : BaseObservable, IContentPage
internal sealed partial class JSContentPageProxy : JSObservableProxyBase, IContentPage
{
private static readonly ConditionalWeakTable<JsonRpcConnection, PageRegistry> Registries = new();
private readonly string _pageId;
private readonly JsonRpcConnection _connection;
private readonly JsonElement _pageData;
private readonly PageRegistry _registry;
public JSContentPageProxy(string pageId, JsonRpcConnection connection, JsonElement pageData = default)
: base(pageId, connection, pageData)
{
_pageId = pageId ?? throw new ArgumentNullException(nameof(pageId));
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_pageData = pageData;
_registry = Registries.GetValue(Connection, static _ => new PageRegistry());
_registry.EnsureSubscribed(Connection);
var pages = _registry.Pages.GetOrAdd(_pageId, static _ => new List<WeakReference<JSContentPageProxy>>());
lock (pages)
{
pages.Add(new WeakReference<JSContentPageProxy>(this));
}
}
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged
{
add { }
remove { }
}
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
public string Id => JSModelMapper.GetString(_pageData, "id") ?? _pageId;
public string Id => JSModelMapper.GetString(Data, "id") ?? _pageId;
public string Name => JSModelMapper.GetString(_pageData, "name") ?? string.Empty;
public string Name => JSModelMapper.GetString(Data, "name") ?? string.Empty;
public IIconInfo Icon => JSModelMapper.GetIcon(_pageData, "icon", "Icon");
public IIconInfo Icon => JSModelMapper.GetIcon(Data, "icon", "Icon");
public string Title => JSModelMapper.GetString(_pageData, "title") ?? Name;
public string Title => JSModelMapper.GetString(Data, "title") ?? Name;
public bool IsLoading => JSModelMapper.GetBool(_pageData, "isLoading", false);
public bool IsLoading => JSModelMapper.GetBool(Data, "isLoading", false);
public OptionalColor AccentColor => JSModelMapper.ParseColor(_pageData, "accentColor", "AccentColor");
public OptionalColor AccentColor => JSModelMapper.ParseColor(Data, "accentColor", "AccentColor");
public IDetails? Details => JSModelMapper.ParseDetails(_pageData, _connection);
public IDetails? Details => JSModelMapper.ParseDetails(Data, Connection);
public IContextItem[] Commands => JSModelMapper.ParseContextItems(_pageData, "commands", "Commands", _connection);
public IContextItem[] Commands => JSModelMapper.ParseContextItems(Data, "commands", "Commands", Connection);
public IContent[] GetContent()
{
try
{
var response = _connection.SendRequestAsync(
var response = Connection.SendRequestAsync(
"contentPage/getContent",
new JsonObject { ["pageId"] = _pageId },
CancellationToken.None).GetAwaiter().GetResult();
@@ -69,7 +78,7 @@ internal sealed partial class JSContentPageProxy : BaseObservable, IContentPage
return [];
}
return JSModelMapper.ParseContentArray(UnwrapContent(response.Result), _pageId, _connection);
return JSModelMapper.ParseContentArray(UnwrapContent(response.Result), _pageId, Connection);
}
catch (Exception ex)
{
@@ -78,6 +87,30 @@ internal sealed partial class JSContentPageProxy : BaseObservable, IContentPage
}
}
public override void Dispose()
{
if (_registry.Pages.TryGetValue(_pageId, out var pages))
{
lock (pages)
{
pages.RemoveAll(weak => !weak.TryGetTarget(out var target) || ReferenceEquals(target, this));
if (pages.Count == 0)
{
_registry.Pages.TryRemove(_pageId, out _);
}
}
}
base.Dispose();
}
protected override bool SupportsProperty(string propertyName) => propertyName switch
{
"id" or "name" or "icon" or "title" or "isLoading" or "accentColor" or
"details" or "commands" => true,
_ => false,
};
private static JsonElement? UnwrapContent(JsonElement? result)
{
if (!result.HasValue)
@@ -93,4 +126,77 @@ internal sealed partial class JSContentPageProxy : BaseObservable, IContentPage
return result;
}
private static void DispatchItemsChanged(PageRegistry registry, JsonElement parameters)
{
try
{
if (parameters.ValueKind != JsonValueKind.Object ||
!parameters.TryGetProperty("pageId", out var pageProperty))
{
return;
}
var pageId = pageProperty.GetString();
if (pageId is null || !registry.Pages.TryGetValue(pageId, out var pageReferences))
{
return;
}
List<JSContentPageProxy> targets = [];
lock (pageReferences)
{
pageReferences.RemoveAll(weak => !weak.TryGetTarget(out _));
foreach (var weak in pageReferences)
{
if (weak.TryGetTarget(out var target))
{
targets.Add(target);
}
}
if (pageReferences.Count == 0)
{
registry.Pages.TryRemove(pageId, out _);
}
}
foreach (var target in targets)
{
var handler = target.ItemsChanged;
if (handler is not null)
{
_ = Task.Run(() => handler.Invoke(target, new ItemsChangedEventArgs(-1)));
}
}
}
catch (Exception ex)
{
Logger.LogWarning($"Error handling contentPage/itemsChanged notification: {ex.Message}");
}
}
private sealed class PageRegistry
{
private readonly object _subscribeLock = new();
private bool _subscribed;
public ConcurrentDictionary<string, List<WeakReference<JSContentPageProxy>>> Pages { get; } = new();
public void EnsureSubscribed(JsonRpcConnection connection)
{
lock (_subscribeLock)
{
if (_subscribed)
{
return;
}
connection.RegisterNotificationHandler(
"contentPage/itemsChanged",
parameters => DispatchItemsChanged(this, parameters));
_subscribed = true;
}
}
}
}

View File

@@ -68,13 +68,13 @@ internal sealed partial class JSFallbackCommandItemAdapter : BaseObservable, IFa
public string DisplayTitle => _displayTitleOverride ?? JSModelMapper.GetString(_data, "displayTitle") ?? Title;
public string Id => JSModelMapper.GetString(_data, "id") ?? string.Empty;
public string Id => JSModelMapper.GetString(_data, "id") ?? Command?.Id ?? string.Empty;
public IFallbackHandler FallbackHandler
{
get
{
_fallbackHandler ??= new JSFallbackHandler(_connection, Id);
_fallbackHandler ??= new JSFallbackHandler(_connection, Command?.Id ?? Id);
return _fallbackHandler;
}
}

View File

@@ -18,28 +18,24 @@ namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// <see cref="IInvokableCommand"/>. Invoking sends a <c>command/invoke</c>
/// request and maps the response to a toolkit command result.
/// </summary>
internal sealed partial class JSInvokableCommandAdapter : BaseObservable, IInvokableCommand
internal sealed partial class JSInvokableCommandAdapter : JSObservableProxyBase, IInvokableCommand
{
private readonly JsonElement _data;
private readonly JsonRpcConnection _connection;
public JSInvokableCommandAdapter(JsonElement data, JsonRpcConnection connection)
: base(JSModelMapper.GetString(data, "id") ?? string.Empty, connection, data)
{
_data = data;
_connection = connection;
}
public string Name => JSModelMapper.GetString(_data, "displayName") ?? JSModelMapper.GetString(_data, "name") ?? string.Empty;
public string Name => JSModelMapper.GetString(Data, "displayName") ?? JSModelMapper.GetString(Data, "name") ?? string.Empty;
public string Id => JSModelMapper.GetString(_data, "id") ?? string.Empty;
public string Id => JSModelMapper.GetString(Data, "id") ?? string.Empty;
public IIconInfo Icon => JSModelMapper.GetIcon(_data, "icon", "Icon");
public IIconInfo Icon => JSModelMapper.GetIcon(Data, "icon", "Icon");
public ICommandResult Invoke(object? sender)
{
try
{
var response = _connection.SendRequestAsync(
var response = Connection.SendRequestAsync(
"command/invoke",
new JsonObject { ["commandId"] = Id },
CancellationToken.None).GetAwaiter().GetResult();
@@ -50,7 +46,7 @@ internal sealed partial class JSInvokableCommandAdapter : BaseObservable, IInvok
return CommandResult.KeepOpen();
}
return JSCommandResultParser.ParseCommandResult(response.Result, _connection);
return JSCommandResultParser.ParseCommandResult(response.Result, Connection);
}
catch (Exception ex)
{
@@ -60,4 +56,10 @@ internal sealed partial class JSInvokableCommandAdapter : BaseObservable, IInvok
}
public ICommandResult Invoke() => Invoke(this);
protected override bool SupportsProperty(string propertyName) => propertyName switch
{
"id" or "name" or "icon" => true,
_ => false,
};
}

View File

@@ -5,6 +5,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -23,7 +24,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// Items are fetched with <c>listPage/getItems</c> and the extension can push
/// <c>listPage/itemsChanged</c> notifications to refresh the view.
/// </summary>
internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisposable
internal sealed partial class JSListPageProxy : JSObservableProxyBase, IListPage
{
// Routing is scoped per connection so that identical page ids from different
// extensions never collide. Each page id maps to the set of live proxies that
@@ -34,18 +35,15 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
private static readonly ConditionalWeakTable<JsonRpcConnection, PageRegistry> Registries = new();
private readonly string _pageId;
private readonly JsonRpcConnection _connection;
private readonly JsonElement _pageData;
private readonly PageRegistry _registry;
private readonly object _stateLock = new();
private bool? _hasMoreItemsState;
private bool _disposed;
public JSListPageProxy(string pageId, JsonRpcConnection connection, JsonElement pageData = default)
: base(pageId, connection, pageData)
{
_pageId = pageId ?? throw new ArgumentNullException(nameof(pageId));
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_pageData = pageData;
// Establish the retained registry first. The factory must stay free of
// side effects: ConditionalWeakTable can invoke it on a thread that then
@@ -54,8 +52,8 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
// that is thrown away while proxies register into a different one. The
// handler is wired exactly once below, against the registry actually
// retained.
_registry = Registries.GetValue(_connection, static _ => new PageRegistry());
_registry.EnsureSubscribed(_connection);
_registry = Registries.GetValue(Connection, static _ => new PageRegistry());
_registry.EnsureSubscribed(Connection);
var list = _registry.Pages.GetOrAdd(_pageId, static _ => new List<WeakReference<JSListPageProxy>>());
lock (list)
@@ -68,37 +66,37 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
public string Id => _pageId;
public string Name => JSModelMapper.GetString(_pageData, "name") ?? string.Empty;
public string Name => JSModelMapper.GetString(Data, "name") ?? string.Empty;
public IIconInfo Icon => JSModelMapper.GetIcon(_pageData, "icon", "Icon");
public IIconInfo Icon => JSModelMapper.GetIcon(Data, "icon", "Icon");
public string Title => JSModelMapper.GetString(_pageData, "title") ?? Name;
public string Title => JSModelMapper.GetString(Data, "title") ?? Name;
public bool IsLoading => JSModelMapper.GetBool(_pageData, "isLoading", false);
public bool IsLoading => JSModelMapper.GetBool(Data, "isLoading", false);
public OptionalColor AccentColor => JSModelMapper.ParseColor(_pageData, "accentColor", "AccentColor");
public OptionalColor AccentColor => JSModelMapper.ParseColor(Data, "accentColor", "AccentColor");
public string SearchText => JSModelMapper.GetString(_pageData, "searchText") ?? string.Empty;
public string SearchText => JSModelMapper.GetString(Data, "searchText") ?? string.Empty;
public string PlaceholderText => JSModelMapper.GetString(_pageData, "placeholderText") ?? string.Empty;
public string PlaceholderText => JSModelMapper.GetString(Data, "placeholderText") ?? string.Empty;
public bool ShowDetails => JSModelMapper.GetBool(_pageData, "showDetails", false);
public bool ShowDetails => JSModelMapper.GetBool(Data, "showDetails", false);
public IFilters? Filters
{
get
{
if (JSModelMapper.TryGetAnyCase(_pageData, "filters", "Filters", out var filtersProp) &&
if (JSModelMapper.TryGetAnyCase(Data, "filters", "Filters", out var filtersProp) &&
filtersProp.ValueKind == JsonValueKind.Object)
{
return new JSFiltersAdapter(filtersProp, _connection, _pageId);
return new JSFiltersAdapter(filtersProp, Connection, _pageId);
}
return null;
}
}
public IGridProperties? GridProperties => JSModelMapper.ParseGridProperties(_pageData);
public IGridProperties? GridProperties => JSModelMapper.ParseGridProperties(Data);
// Pagination state is mutable: the extension reports whether more pages
// remain via the getItems / loadMore responses and itemsChanged
@@ -110,7 +108,7 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
{
lock (_stateLock)
{
return _hasMoreItemsState ?? JSModelMapper.GetBool(_pageData, "hasMoreItems", false);
return _hasMoreItemsState ?? JSModelMapper.GetBool(Data, "hasMoreItems", false);
}
}
}
@@ -119,10 +117,10 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
{
get
{
if (JSModelMapper.TryGetAnyCase(_pageData, "emptyContent", "EmptyContent", out var emptyProp) &&
if (JSModelMapper.TryGetAnyCase(Data, "emptyContent", "EmptyContent", out var emptyProp) &&
emptyProp.ValueKind == JsonValueKind.Object)
{
return new JSCommandItemAdapter(emptyProp, _connection);
return new JSCommandItemAdapter(emptyProp, Connection);
}
return null;
@@ -133,7 +131,7 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
{
try
{
var response = _connection.SendRequestAsync(
var response = Connection.SendRequestAsync(
"listPage/getItems",
new JsonObject { ["pageId"] = _pageId },
CancellationToken.None).GetAwaiter().GetResult();
@@ -167,7 +165,7 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
try
{
var response = _connection.SendRequestAsync(
var response = Connection.SendRequestAsync(
"listPage/loadMore",
new JsonObject { ["pageId"] = _pageId },
CancellationToken.None).GetAwaiter().GetResult();
@@ -316,7 +314,7 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems));
}
public void Dispose()
public override void Dispose()
{
if (_disposed)
{
@@ -325,6 +323,8 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
_disposed = true;
base.Dispose();
if (_registry.Pages.TryGetValue(_pageId, out var list))
{
lock (list)
@@ -399,6 +399,28 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
}
}
protected override bool SupportsProperty(string propertyName) => propertyName switch
{
"id" or "name" or "icon" or "title" or "isLoading" or "accentColor" or
"searchText" or "placeholderText" or "showDetails" or "filters" or
"gridProperties" or "hasMoreItems" or "emptyContent" => true,
_ => false,
};
protected override void OnPropertyChangesApplied(IReadOnlyList<string> propertyNames)
{
if (!propertyNames.Contains("hasMoreItems"))
{
return;
}
var value = JSModelMapper.GetBool(Data, "hasMoreItems", false);
lock (_stateLock)
{
_hasMoreItemsState = value;
}
}
private IListItem[] ParseListItems(JsonElement? result)
{
if (!result.HasValue)
@@ -428,7 +450,7 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
}
else
{
items.Add(new JSListItemAdapter(element, _connection));
items.Add(new JSListItemAdapter(element, Connection));
}
}

View File

@@ -0,0 +1,106 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels.Services.JsonRpc;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
internal abstract class JSObservableProxyBase : BaseObservable, IJSPropertyChangeTarget, IDisposable
{
private readonly string _commandId;
private readonly JsonRpcConnection _connection;
private DataBox _data;
private bool _disposed;
protected JSObservableProxyBase(string commandId, JsonRpcConnection connection, JsonElement data)
{
_commandId = commandId ?? throw new ArgumentNullException(nameof(commandId));
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
_data = new DataBox(data);
JSPropertyChangeRegistry.Register(connection, commandId, this);
}
protected JsonRpcConnection Connection => _connection;
protected JsonElement Data => Volatile.Read(ref _data).Element;
protected abstract bool SupportsProperty(string propertyName);
public void ApplyPropertyChanges(JsonElement properties)
{
var current = Data;
if (current.ValueKind != JsonValueKind.Object)
{
return;
}
var changed = new List<string>();
var merged = JsonNode.Parse(current.GetRawText()) as JsonObject;
if (merged is null)
{
return;
}
foreach (var property in properties.EnumerateObject())
{
if (!SupportsProperty(property.Name))
{
continue;
}
merged[property.Name] = JsonNode.Parse(property.Value.GetRawText());
changed.Add(property.Name);
}
if (changed.Count == 0)
{
return;
}
using var document = JsonDocument.Parse(merged.ToJsonString());
Volatile.Write(ref _data, new DataBox(document.RootElement.Clone()));
OnPropertyChangesApplied(changed);
foreach (var property in changed)
{
OnPropertyChanged(ToAbiPropertyName(property));
}
}
protected virtual void OnPropertyChangesApplied(IReadOnlyList<string> propertyNames)
{
}
public virtual void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
JSPropertyChangeRegistry.Unregister(_connection, _commandId, this);
}
private static string ToAbiPropertyName(string propertyName)
{
return propertyName.Length == 0
? propertyName
: char.ToUpperInvariant(propertyName[0]) + propertyName[1..];
}
private sealed class DataBox
{
internal DataBox(JsonElement element) => Element = element;
internal JsonElement Element { get; }
}
}

View File

@@ -0,0 +1,88 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.CmdPal.UI.ViewModels.Services.JsonRpc;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
internal static class JSPropertyChangeRegistry
{
private static readonly ConditionalWeakTable<JsonRpcConnection, Registry> Registries = new();
internal static void Register(JsonRpcConnection connection, string commandId, IJSPropertyChangeTarget target)
{
var registry = Registries.GetValue(connection, static _ => new Registry());
var targets = registry.Targets.GetOrAdd(commandId, static _ => []);
lock (targets)
{
targets.Add(new WeakReference<IJSPropertyChangeTarget>(target));
}
}
internal static void Unregister(JsonRpcConnection connection, string commandId, IJSPropertyChangeTarget target)
{
if (!Registries.TryGetValue(connection, out var registry) ||
!registry.Targets.TryGetValue(commandId, out var targets))
{
return;
}
lock (targets)
{
targets.RemoveAll(reference =>
!reference.TryGetTarget(out var current) || ReferenceEquals(current, target));
if (targets.Count == 0)
{
registry.Targets.TryRemove(commandId, out _);
}
}
}
internal static void Dispatch(JsonRpcConnection connection, JsonElement paramsElement)
{
if (paramsElement.ValueKind != JsonValueKind.Object ||
!paramsElement.TryGetProperty("commandId", out var commandIdProperty) ||
commandIdProperty.ValueKind != JsonValueKind.String ||
!paramsElement.TryGetProperty("properties", out var properties) ||
properties.ValueKind != JsonValueKind.Object ||
!Registries.TryGetValue(connection, out var registry))
{
return;
}
var commandId = commandIdProperty.GetString();
if (commandId is null || !registry.Targets.TryGetValue(commandId, out var targets))
{
return;
}
List<IJSPropertyChangeTarget> liveTargets = [];
lock (targets)
{
targets.RemoveAll(reference => !reference.TryGetTarget(out _));
foreach (var reference in targets)
{
if (reference.TryGetTarget(out var target))
{
liveTargets.Add(target);
}
}
}
foreach (var target in liveTargets)
{
target.ApplyPropertyChanges(properties);
}
}
private sealed class Registry
{
internal ConcurrentDictionary<string, List<WeakReference<IJSPropertyChangeTarget>>> Targets { get; } = new();
}
}

View File

@@ -86,10 +86,16 @@ public class JSAdapterProxyTests
Assert.AreEqual(CommandResultKind.GoToPage, goToPage.Kind);
Assert.AreEqual("target-page", ((IGoToPageArgs)goToPage.Args).PageId);
fake.OnResult("command/invoke", """{ "Kind": 6, "Args": { "message": "toasted" } }""");
fake.OnResult(
"command/invoke",
"""{ "Kind": 6, "Args": { "message": "toasted", "icon": { "light": { "icon": "\uE700" } }, "command": { "id": "undo", "name": "Undo" } } }""");
var toast = invokable.Invoke(null);
Assert.AreEqual(CommandResultKind.ShowToast, toast.Kind);
Assert.AreEqual("toasted", ((IToastArgs)toast.Args).Message);
var toastArgs2 = (IToastArgs2)toast.Args;
Assert.AreEqual("\uE700", toastArgs2.Icon.Light.Icon);
Assert.AreEqual("undo", toastArgs2.Command.Id);
Assert.AreEqual("Undo", toastArgs2.Command.Name);
fake.OnResult("command/invoke", """{ "Kind": 7, "Args": { "title": "Are you sure?" } }""");
var confirm = invokable.Invoke(null);
@@ -97,6 +103,25 @@ public class JSAdapterProxyTests
Assert.AreEqual("Are you sure?", ((IConfirmationArgs)confirm.Args).Title);
}
[TestMethod]
public void GetCommandItem_MapsFullCommandItem()
{
using var fake = new JSFakeExtension();
fake.OnResult(
"provider/getCommandItem",
"""{ "id": "pinned", "title": "Pinned", "subtitle": "From anywhere", "command": { "id": "pinned", "name": "Pinned" }, "moreCommands": [] }""");
var provider = CreateProvider(fake);
var item = ((ICommandProvider4)provider).GetCommandItem("pinned");
Assert.IsNotNull(item);
Assert.AreEqual("Pinned", item.Title);
Assert.AreEqual("From anywhere", item.Subtitle);
Assert.IsNotNull(item.Command);
Assert.AreEqual("pinned", item.Command.Id);
Assert.AreEqual("Pinned", item.Command.Name);
}
[TestMethod]
public void ListPage_MapsItemsTagsDetailsSectionsSeparatorsAndMoreCommands()
{
@@ -267,6 +292,34 @@ public class JSAdapterProxyTests
Assert.AreEqual(CommandResultKind.Hide, submitResult.Kind);
}
[TestMethod]
public async Task ContentPage_NotificationRaisesItemsChangedAndRefreshesContent()
{
using var fake = new JSFakeExtension();
fake.OnResult("provider/getCommand", """{ "id": "content-refresh", "pageType": "contentPage", "name": "Content" }""");
var contentBody = "initial";
fake.OnRequest("contentPage/getContent", _ => new JsonArray(
new JsonObject { ["type"] = "plainText", ["text"] = contentBody }));
var provider = CreateProvider(fake);
var page = (IContentPage)provider.GetCommand("content-refresh")!;
var initial = (IPlainTextContent)page.GetContent()[0];
Assert.AreEqual("initial", initial.Text);
var raised = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
page.ItemsChanged += (_, args) => raised.TrySetResult(args.TotalItems);
contentBody = "updated";
await fake.PushNotificationAsync(
"contentPage/itemsChanged",
new JsonObject { ["pageId"] = "content-refresh" });
Assert.AreEqual(-1, await raised.Task.WaitAsync(TimeSpan.FromSeconds(10)));
var refreshed = (IPlainTextContent)page.GetContent()[0];
Assert.AreEqual("updated", refreshed.Text);
}
[TestMethod]
public async Task FallbackCommands_UpdateDisplayTitleOnPropChanged()
{
@@ -362,22 +415,56 @@ public class JSAdapterProxyTests
using var fake = new JSFakeExtension();
fake.OnResult(
"provider/getFallbackCommands",
"""[ { "id": "fb-req", "displayTitle": "Initial", "title": "T" } ]""");
"""[ { "id": "fallback-item", "displayTitle": "Initial", "title": "T", "command": { "id": "fallback-command", "name": "T" } } ]""");
var received = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
fake.OnRequest("fallback/updateQuery", element =>
{
received.TrySetResult(element.GetProperty("query").GetString() ?? string.Empty);
received.TrySetResult(
$"{element.GetProperty("commandId").GetString()}|{element.GetProperty("query").GetString()}");
return null;
});
var provider = CreateProvider(fake);
var fallback = provider.FallbackCommands()![0];
Assert.IsInstanceOfType(fallback, typeof(IFallbackCommandItem2));
Assert.AreEqual("fallback-item", ((IFallbackCommandItem2)fallback).Id);
await Task.Run(() => fallback.FallbackHandler.UpdateQuery("typed"));
var query = await received.Task.WaitAsync(Timeout);
Assert.AreEqual("typed", query);
var request = await received.Task.WaitAsync(Timeout);
Assert.AreEqual("fallback-command|typed", request);
}
[TestMethod]
public async Task CommandPropChanged_UpdatesPageStateAndRaisesAbiProperty()
{
using var fake = new JSFakeExtension();
fake.OnResult(
"provider/getCommand",
"""{ "id": "live-page", "pageType": "listPage", "name": "Page", "title": "Old", "isLoading": false }""");
var provider = CreateProvider(fake);
var page = (IListPage)provider.GetCommand("live-page")!;
var changed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
((INotifyPropChanged)page).PropChanged += (_, args) => changed.TrySetResult(args.PropertyName);
await fake.PushNotificationAsync(
"command/propChanged",
new JsonObject
{
["commandId"] = "live-page",
["properties"] = new JsonObject
{
["isLoading"] = true,
["title"] = "New",
},
});
Assert.AreEqual("IsLoading", await changed.Task.WaitAsync(Timeout));
Assert.IsTrue(page.IsLoading);
Assert.AreEqual("New", page.Title);
}
private static void AssertKind(JSFakeExtension fake, IInvokableCommand invokable, string resultJson, CommandResultKind expected)

View File

@@ -92,6 +92,21 @@ public partial class JSAdapterRemediationTests
Assert.AreEqual(CommandResultKind.GoHome, toastArgs.Result!.Kind);
}
[TestMethod]
public void Toast_ParsesIconAndGracefullyOmitsActionWithoutConnection()
{
using var document = JsonDocument.Parse(
"""{ "Kind": 6, "Args": { "Message": "Saved", "Icon": { "light": { "icon": "\uE700" } }, "Command": { "id": "undo", "name": "Undo" } } }""");
var result = JSCommandResultParser.ParseCommandResult(document.RootElement, null);
var toastArgs = (IToastArgs2)result.Args;
Assert.AreEqual(CommandResultKind.ShowToast, result.Kind);
Assert.AreEqual("Saved", toastArgs.Message);
Assert.AreEqual("\uE700", toastArgs.Icon.Light.Icon);
Assert.IsNull(toastArgs.Command);
}
// p3-03: two references to the same pageId both receive items-changed.
[TestMethod]
public async Task ListPage_DuplicatePageReferencesBothReceiveNotifications()

View File

@@ -0,0 +1,200 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Exercises the host/showStatus notification path end to end against an in-memory
/// fake extension, verifying that the host reads the SDK status wire shape correctly:
/// the indeterminate progress payload and the Pascal-case State severity nested in
/// the message object.
/// </summary>
[TestClass]
public partial class JSStatusNotificationTests
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
[TestMethod]
public async Task ShowStatus_IndeterminateProgress_SetsProgressStateOnStatusMessage()
{
using var fake = new JSFakeExtension();
var provider = CreateInitializedProvider(fake, out var host);
await fake.PushNotificationAsync(
"host/showStatus",
new JsonObject
{
["message"] = new JsonObject
{
["Message"] = "Working",
["State"] = 0,
},
["progress"] = new JsonObject { ["isIndeterminate"] = true },
["context"] = "extension",
});
var status = await host.WaitForStatusAsync();
Assert.AreEqual("Working", status.Message);
Assert.IsNotNull(status.Progress);
Assert.IsTrue(status.Progress!.IsIndeterminate);
GC.KeepAlive(provider);
}
[TestMethod]
public async Task ShowStatus_DeterminateProgress_MapsProgressPercent()
{
using var fake = new JSFakeExtension();
var provider = CreateInitializedProvider(fake, out var host);
await fake.PushNotificationAsync(
"host/showStatus",
new JsonObject
{
["message"] = new JsonObject
{
["Message"] = "Half done",
["State"] = 0,
},
["progress"] = new JsonObject
{
["isIndeterminate"] = false,
["progressPercent"] = 50,
},
["context"] = "extension",
});
var status = await host.WaitForStatusAsync();
Assert.IsNotNull(status.Progress);
Assert.IsFalse(status.Progress!.IsIndeterminate);
Assert.AreEqual(50u, status.Progress.ProgressPercent);
GC.KeepAlive(provider);
}
[DataTestMethod]
[DataRow(0, MessageState.Info)]
[DataRow(1, MessageState.Success)]
[DataRow(2, MessageState.Warning)]
[DataRow(3, MessageState.Error)]
public async Task ShowStatus_PascalCaseState_MapsToSeverity(int stateValue, MessageState expected)
{
using var fake = new JSFakeExtension();
var provider = CreateInitializedProvider(fake, out var host);
await fake.PushNotificationAsync(
"host/showStatus",
new JsonObject
{
["message"] = new JsonObject
{
["Message"] = $"State {stateValue}",
["State"] = stateValue,
},
["context"] = "extension",
});
var status = await host.WaitForStatusAsync();
Assert.AreEqual(expected, status.State);
Assert.IsNull(status.Progress);
GC.KeepAlive(provider);
}
[TestMethod]
public async Task ShowStatus_PageContext_MapsToPageScope()
{
using var fake = new JSFakeExtension();
var provider = CreateInitializedProvider(fake, out var host);
await fake.PushNotificationAsync(
"host/showStatus",
new JsonObject
{
["message"] = new JsonObject
{
["Message"] = "Page status",
["State"] = 0,
},
["context"] = "page",
});
await host.WaitForStatusAsync();
Assert.AreEqual(StatusContext.Page, host.LastContext);
GC.KeepAlive(provider);
}
[TestMethod]
public async Task ShowStatus_OmittedContext_DefaultsToExtensionScope()
{
using var fake = new JSFakeExtension();
var provider = CreateInitializedProvider(fake, out var host);
await fake.PushNotificationAsync(
"host/showStatus",
new JsonObject
{
["message"] = new JsonObject
{
["Message"] = "Extension status",
["State"] = 0,
},
});
await host.WaitForStatusAsync();
Assert.AreEqual(StatusContext.Extension, host.LastContext);
GC.KeepAlive(provider);
}
private static JSCommandProviderProxy CreateInitializedProvider(JSFakeExtension fake, out CapturingExtensionHost host)
{
host = new CapturingExtensionHost();
var provider = new JSCommandProviderProxy(
fake.Connection,
new JSExtensionManifest { Name = "test.ext", DisplayName = "Test Extension" });
provider.InitializeWithHost(host);
return provider;
}
private sealed partial class CapturingExtensionHost : IExtensionHost
{
private readonly TaskCompletionSource<IStatusMessage> _statusShown =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public StatusContext LastContext { get; private set; } = StatusContext.Extension;
public Task<IStatusMessage> WaitForStatusAsync() => _statusShown.Task.WaitAsync(Timeout);
public IAsyncAction ShowStatus(IStatusMessage? message, StatusContext context)
{
if (message is not null)
{
LastContext = context;
_statusShown.TrySetResult(message);
}
return Task.CompletedTask.AsAsyncAction();
}
public IAsyncAction HideStatus(IStatusMessage? message) => Task.CompletedTask.AsAsyncAction();
public IAsyncAction LogMessage(ILogMessage? message) => Task.CompletedTask.AsAsyncAction();
}
}