mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 10:09:43 +02:00
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:
committed by
Michael Jolley
parent
72807b5916
commit
e6d553b888
@@ -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);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Models;
|
|||||||
/// provider calls over JSON-RPC. Fallback display titles, host status messages,
|
/// provider calls over JSON-RPC. Fallback display titles, host status messages,
|
||||||
/// log messages and clipboard requests raised by the extension are handled here.
|
/// log messages and clipboard requests raised by the extension are handled here.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed partial class JSCommandProviderProxy : ICommandProvider, IDisposable
|
public sealed partial class JSCommandProviderProxy : ICommandProvider4, IDisposable
|
||||||
{
|
{
|
||||||
private readonly JsonRpcConnection _connection;
|
private readonly JsonRpcConnection _connection;
|
||||||
private readonly JSExtensionManifest _manifest;
|
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)
|
public void InitializeWithHost(IExtensionHost host)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(host);
|
ArgumentNullException.ThrowIfNull(host);
|
||||||
@@ -369,6 +402,8 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider, IDisposab
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
JSPropertyChangeRegistry.Dispatch(_connection, paramsElement);
|
||||||
|
|
||||||
var commandId = JSModelMapper.GetString(paramsElement, "commandId") ?? string.Empty;
|
var commandId = JSModelMapper.GetString(paramsElement, "commandId") ?? string.Empty;
|
||||||
if (string.IsNullOrEmpty(commandId) ||
|
if (string.IsNullOrEmpty(commandId) ||
|
||||||
!_fallbackAdapters.TryGetValue(commandId, out var fallbackAdapter))
|
!_fallbackAdapters.TryGetValue(commandId, out var fallbackAdapter))
|
||||||
@@ -716,9 +751,14 @@ public sealed partial class JSCommandProviderProxy : ICommandProvider, IDisposab
|
|||||||
return (StatusContext)contextProp.GetInt32();
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,23 @@ internal static class JSCommandResultParser
|
|||||||
|
|
||||||
var toastArgs = new ToastArgs { Message = message };
|
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
|
// A toast can carry a nested continuation result that the shell executes
|
||||||
// after the toast is shown. Parse it recursively so every nested kind
|
// after the toast is shown. Parse it recursively so every nested kind
|
||||||
// (including confirm, which needs the connection for its primary command,
|
// (including confirm, which needs the connection for its primary command,
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
// See the LICENSE file in the project root for more information.
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using ManagedCommon;
|
using ManagedCommon;
|
||||||
using Microsoft.CmdPal.UI.ViewModels.Services.JsonRpc;
|
using Microsoft.CmdPal.UI.ViewModels.Services.JsonRpc;
|
||||||
using Microsoft.CommandPalette.Extensions;
|
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
|
/// Content is fetched with <c>contentPage/getContent</c>; details and commands are
|
||||||
/// materialized from the page payload.
|
/// materialized from the page payload.
|
||||||
/// </summary>
|
/// </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 string _pageId;
|
||||||
private readonly JsonRpcConnection _connection;
|
private readonly PageRegistry _registry;
|
||||||
private readonly JsonElement _pageData;
|
|
||||||
|
|
||||||
public JSContentPageProxy(string pageId, JsonRpcConnection connection, JsonElement pageData = default)
|
public JSContentPageProxy(string pageId, JsonRpcConnection connection, JsonElement pageData = default)
|
||||||
|
: base(pageId, connection, pageData)
|
||||||
{
|
{
|
||||||
_pageId = pageId ?? throw new ArgumentNullException(nameof(pageId));
|
_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
|
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
|
||||||
{
|
|
||||||
add { }
|
|
||||||
remove { }
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
public IContent[] GetContent()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = _connection.SendRequestAsync(
|
var response = Connection.SendRequestAsync(
|
||||||
"contentPage/getContent",
|
"contentPage/getContent",
|
||||||
new JsonObject { ["pageId"] = _pageId },
|
new JsonObject { ["pageId"] = _pageId },
|
||||||
CancellationToken.None).GetAwaiter().GetResult();
|
CancellationToken.None).GetAwaiter().GetResult();
|
||||||
@@ -69,7 +78,7 @@ internal sealed partial class JSContentPageProxy : BaseObservable, IContentPage
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSModelMapper.ParseContentArray(UnwrapContent(response.Result), _pageId, _connection);
|
return JSModelMapper.ParseContentArray(UnwrapContent(response.Result), _pageId, Connection);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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)
|
private static JsonElement? UnwrapContent(JsonElement? result)
|
||||||
{
|
{
|
||||||
if (!result.HasValue)
|
if (!result.HasValue)
|
||||||
@@ -93,4 +126,77 @@ internal sealed partial class JSContentPageProxy : BaseObservable, IContentPage
|
|||||||
|
|
||||||
return result;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,13 +68,13 @@ internal sealed partial class JSFallbackCommandItemAdapter : BaseObservable, IFa
|
|||||||
|
|
||||||
public string DisplayTitle => _displayTitleOverride ?? JSModelMapper.GetString(_data, "displayTitle") ?? Title;
|
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
|
public IFallbackHandler FallbackHandler
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
_fallbackHandler ??= new JSFallbackHandler(_connection, Id);
|
_fallbackHandler ??= new JSFallbackHandler(_connection, Command?.Id ?? Id);
|
||||||
return _fallbackHandler;
|
return _fallbackHandler;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,28 +18,24 @@ namespace Microsoft.CmdPal.UI.ViewModels.Models;
|
|||||||
/// <see cref="IInvokableCommand"/>. Invoking sends a <c>command/invoke</c>
|
/// <see cref="IInvokableCommand"/>. Invoking sends a <c>command/invoke</c>
|
||||||
/// request and maps the response to a toolkit command result.
|
/// request and maps the response to a toolkit command result.
|
||||||
/// </summary>
|
/// </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)
|
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)
|
public ICommandResult Invoke(object? sender)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = _connection.SendRequestAsync(
|
var response = Connection.SendRequestAsync(
|
||||||
"command/invoke",
|
"command/invoke",
|
||||||
new JsonObject { ["commandId"] = Id },
|
new JsonObject { ["commandId"] = Id },
|
||||||
CancellationToken.None).GetAwaiter().GetResult();
|
CancellationToken.None).GetAwaiter().GetResult();
|
||||||
@@ -50,7 +46,7 @@ internal sealed partial class JSInvokableCommandAdapter : BaseObservable, IInvok
|
|||||||
return CommandResult.KeepOpen();
|
return CommandResult.KeepOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSCommandResultParser.ParseCommandResult(response.Result, _connection);
|
return JSCommandResultParser.ParseCommandResult(response.Result, Connection);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -60,4 +56,10 @@ internal sealed partial class JSInvokableCommandAdapter : BaseObservable, IInvok
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ICommandResult Invoke() => Invoke(this);
|
public ICommandResult Invoke() => Invoke(this);
|
||||||
|
|
||||||
|
protected override bool SupportsProperty(string propertyName) => propertyName switch
|
||||||
|
{
|
||||||
|
"id" or "name" or "icon" => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
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
|
/// Items are fetched with <c>listPage/getItems</c> and the extension can push
|
||||||
/// <c>listPage/itemsChanged</c> notifications to refresh the view.
|
/// <c>listPage/itemsChanged</c> notifications to refresh the view.
|
||||||
/// </summary>
|
/// </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
|
// 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
|
// 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 static readonly ConditionalWeakTable<JsonRpcConnection, PageRegistry> Registries = new();
|
||||||
|
|
||||||
private readonly string _pageId;
|
private readonly string _pageId;
|
||||||
private readonly JsonRpcConnection _connection;
|
|
||||||
private readonly JsonElement _pageData;
|
|
||||||
private readonly PageRegistry _registry;
|
private readonly PageRegistry _registry;
|
||||||
private readonly object _stateLock = new();
|
private readonly object _stateLock = new();
|
||||||
private bool? _hasMoreItemsState;
|
private bool? _hasMoreItemsState;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public JSListPageProxy(string pageId, JsonRpcConnection connection, JsonElement pageData = default)
|
public JSListPageProxy(string pageId, JsonRpcConnection connection, JsonElement pageData = default)
|
||||||
|
: base(pageId, connection, pageData)
|
||||||
{
|
{
|
||||||
_pageId = pageId ?? throw new ArgumentNullException(nameof(pageId));
|
_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
|
// Establish the retained registry first. The factory must stay free of
|
||||||
// side effects: ConditionalWeakTable can invoke it on a thread that then
|
// 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
|
// that is thrown away while proxies register into a different one. The
|
||||||
// handler is wired exactly once below, against the registry actually
|
// handler is wired exactly once below, against the registry actually
|
||||||
// retained.
|
// retained.
|
||||||
_registry = Registries.GetValue(_connection, static _ => new PageRegistry());
|
_registry = Registries.GetValue(Connection, static _ => new PageRegistry());
|
||||||
_registry.EnsureSubscribed(_connection);
|
_registry.EnsureSubscribed(Connection);
|
||||||
|
|
||||||
var list = _registry.Pages.GetOrAdd(_pageId, static _ => new List<WeakReference<JSListPageProxy>>());
|
var list = _registry.Pages.GetOrAdd(_pageId, static _ => new List<WeakReference<JSListPageProxy>>());
|
||||||
lock (list)
|
lock (list)
|
||||||
@@ -68,37 +66,37 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
|
|||||||
|
|
||||||
public string Id => _pageId;
|
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
|
public IFilters? Filters
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (JSModelMapper.TryGetAnyCase(_pageData, "filters", "Filters", out var filtersProp) &&
|
if (JSModelMapper.TryGetAnyCase(Data, "filters", "Filters", out var filtersProp) &&
|
||||||
filtersProp.ValueKind == JsonValueKind.Object)
|
filtersProp.ValueKind == JsonValueKind.Object)
|
||||||
{
|
{
|
||||||
return new JSFiltersAdapter(filtersProp, _connection, _pageId);
|
return new JSFiltersAdapter(filtersProp, Connection, _pageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public IGridProperties? GridProperties => JSModelMapper.ParseGridProperties(_pageData);
|
public IGridProperties? GridProperties => JSModelMapper.ParseGridProperties(Data);
|
||||||
|
|
||||||
// Pagination state is mutable: the extension reports whether more pages
|
// Pagination state is mutable: the extension reports whether more pages
|
||||||
// remain via the getItems / loadMore responses and itemsChanged
|
// remain via the getItems / loadMore responses and itemsChanged
|
||||||
@@ -110,7 +108,7 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
|
|||||||
{
|
{
|
||||||
lock (_stateLock)
|
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
|
get
|
||||||
{
|
{
|
||||||
if (JSModelMapper.TryGetAnyCase(_pageData, "emptyContent", "EmptyContent", out var emptyProp) &&
|
if (JSModelMapper.TryGetAnyCase(Data, "emptyContent", "EmptyContent", out var emptyProp) &&
|
||||||
emptyProp.ValueKind == JsonValueKind.Object)
|
emptyProp.ValueKind == JsonValueKind.Object)
|
||||||
{
|
{
|
||||||
return new JSCommandItemAdapter(emptyProp, _connection);
|
return new JSCommandItemAdapter(emptyProp, Connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -133,7 +131,7 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = _connection.SendRequestAsync(
|
var response = Connection.SendRequestAsync(
|
||||||
"listPage/getItems",
|
"listPage/getItems",
|
||||||
new JsonObject { ["pageId"] = _pageId },
|
new JsonObject { ["pageId"] = _pageId },
|
||||||
CancellationToken.None).GetAwaiter().GetResult();
|
CancellationToken.None).GetAwaiter().GetResult();
|
||||||
@@ -167,7 +165,7 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = _connection.SendRequestAsync(
|
var response = Connection.SendRequestAsync(
|
||||||
"listPage/loadMore",
|
"listPage/loadMore",
|
||||||
new JsonObject { ["pageId"] = _pageId },
|
new JsonObject { ["pageId"] = _pageId },
|
||||||
CancellationToken.None).GetAwaiter().GetResult();
|
CancellationToken.None).GetAwaiter().GetResult();
|
||||||
@@ -316,7 +314,7 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
|
|||||||
ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems));
|
ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public override void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
{
|
{
|
||||||
@@ -325,6 +323,8 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
|
|||||||
|
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
|
|
||||||
|
base.Dispose();
|
||||||
|
|
||||||
if (_registry.Pages.TryGetValue(_pageId, out var list))
|
if (_registry.Pages.TryGetValue(_pageId, out var list))
|
||||||
{
|
{
|
||||||
lock (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)
|
private IListItem[] ParseListItems(JsonElement? result)
|
||||||
{
|
{
|
||||||
if (!result.HasValue)
|
if (!result.HasValue)
|
||||||
@@ -428,7 +450,7 @@ internal sealed partial class JSListPageProxy : BaseObservable, IListPage, IDisp
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
items.Add(new JSListItemAdapter(element, _connection));
|
items.Add(new JSListItemAdapter(element, Connection));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,10 +86,16 @@ public class JSAdapterProxyTests
|
|||||||
Assert.AreEqual(CommandResultKind.GoToPage, goToPage.Kind);
|
Assert.AreEqual(CommandResultKind.GoToPage, goToPage.Kind);
|
||||||
Assert.AreEqual("target-page", ((IGoToPageArgs)goToPage.Args).PageId);
|
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);
|
var toast = invokable.Invoke(null);
|
||||||
Assert.AreEqual(CommandResultKind.ShowToast, toast.Kind);
|
Assert.AreEqual(CommandResultKind.ShowToast, toast.Kind);
|
||||||
Assert.AreEqual("toasted", ((IToastArgs)toast.Args).Message);
|
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?" } }""");
|
fake.OnResult("command/invoke", """{ "Kind": 7, "Args": { "title": "Are you sure?" } }""");
|
||||||
var confirm = invokable.Invoke(null);
|
var confirm = invokable.Invoke(null);
|
||||||
@@ -97,6 +103,25 @@ public class JSAdapterProxyTests
|
|||||||
Assert.AreEqual("Are you sure?", ((IConfirmationArgs)confirm.Args).Title);
|
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]
|
[TestMethod]
|
||||||
public void ListPage_MapsItemsTagsDetailsSectionsSeparatorsAndMoreCommands()
|
public void ListPage_MapsItemsTagsDetailsSectionsSeparatorsAndMoreCommands()
|
||||||
{
|
{
|
||||||
@@ -267,6 +292,34 @@ public class JSAdapterProxyTests
|
|||||||
Assert.AreEqual(CommandResultKind.Hide, submitResult.Kind);
|
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]
|
[TestMethod]
|
||||||
public async Task FallbackCommands_UpdateDisplayTitleOnPropChanged()
|
public async Task FallbackCommands_UpdateDisplayTitleOnPropChanged()
|
||||||
{
|
{
|
||||||
@@ -362,22 +415,56 @@ public class JSAdapterProxyTests
|
|||||||
using var fake = new JSFakeExtension();
|
using var fake = new JSFakeExtension();
|
||||||
fake.OnResult(
|
fake.OnResult(
|
||||||
"provider/getFallbackCommands",
|
"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);
|
var received = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
fake.OnRequest("fallback/updateQuery", element =>
|
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;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
var provider = CreateProvider(fake);
|
var provider = CreateProvider(fake);
|
||||||
var fallback = provider.FallbackCommands()![0];
|
var fallback = provider.FallbackCommands()![0];
|
||||||
|
|
||||||
|
Assert.IsInstanceOfType(fallback, typeof(IFallbackCommandItem2));
|
||||||
|
Assert.AreEqual("fallback-item", ((IFallbackCommandItem2)fallback).Id);
|
||||||
|
|
||||||
await Task.Run(() => fallback.FallbackHandler.UpdateQuery("typed"));
|
await Task.Run(() => fallback.FallbackHandler.UpdateQuery("typed"));
|
||||||
|
|
||||||
var query = await received.Task.WaitAsync(Timeout);
|
var request = await received.Task.WaitAsync(Timeout);
|
||||||
Assert.AreEqual("typed", query);
|
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)
|
private static void AssertKind(JSFakeExtension fake, IInvokableCommand invokable, string resultJson, CommandResultKind expected)
|
||||||
|
|||||||
@@ -92,6 +92,21 @@ public partial class JSAdapterRemediationTests
|
|||||||
Assert.AreEqual(CommandResultKind.GoHome, toastArgs.Result!.Kind);
|
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.
|
// p3-03: two references to the same pageId both receive items-changed.
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public async Task ListPage_DuplicatePageReferencesBothReceiveNotifications()
|
public async Task ListPage_DuplicatePageReferencesBothReceiveNotifications()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user