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,
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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("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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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