mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 10:09:43 +02:00
[CmdPal] JS/TS Extensions Phase 6: docs + parity sample extension
Adds the JSON-RPC spec and manifest/packaging developer docs plus a parity sample extension, documents the npm author publisher fallback, removes the unreferenced JsExtensionPackageLayout dead code, and corrects the doc 04 install flow to match the real npm pack/ci/promote sequence. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d243b1e9-40fb-4aed-aa60-beb5e80f7d91
This commit is contained in:
committed by
Michael Jolley
parent
292288ea74
commit
68c38428e1
@@ -284,6 +284,78 @@ public class ExtensionGalleryServiceTests
|
||||
Assert.AreEqual(new Uri(expectedIconPath).AbsoluteUri, result.Extensions[0].IconUrl);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task FetchExtensionsAsync_ParsesDocumentedJsonRpcGalleryExample()
|
||||
{
|
||||
// This JSON is the gallery feed example published in
|
||||
// doc/json-rpc-spec/04-manifest-packaging.md. It is fed through the production parser so
|
||||
// the documentation and the real gallery model can never drift apart. The example omits
|
||||
// iconUrl so the parse stays hermetic (no icon is fetched over the network).
|
||||
var feedDirectory = CreateTempDirectory("feed");
|
||||
var cacheDirectory = CreateTempDirectory("cache");
|
||||
|
||||
var documentedJson = """
|
||||
{
|
||||
"extensions": [
|
||||
{
|
||||
"id": "publisher.cmdpal-my-extension",
|
||||
"title": "My Extension",
|
||||
"description": "Does amazing things from the Command Palette.",
|
||||
"shortDescription": "Does amazing things.",
|
||||
"author": {
|
||||
"name": "Your Name",
|
||||
"url": "https://example.com"
|
||||
},
|
||||
"homepage": "https://example.com/my-extension",
|
||||
"tags": ["cmdpal", "productivity"],
|
||||
"installSources": [
|
||||
{
|
||||
"type": "jsonrpc",
|
||||
"npm": {
|
||||
"package": "@publisher/cmdpal-my-extension",
|
||||
"version": "1.0.0",
|
||||
"integrity": "sha512-3sxT2b3Ea2u2vLXA7Yl0dOZH3Rm9j1p3T0i8b9m2wJ0kZ8t2K1cQ0f8p7L6r5S4d3F2a1B0c9D8e7F6g5H4i3J2k1L0m9N8o7P6q5R4s3T2u1V0w==",
|
||||
"registry": "https://registry.npmjs.org"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
File.WriteAllText(Path.Combine(feedDirectory, "extensions.json"), documentedJson);
|
||||
|
||||
var feedUrl = ToFeedUri(feedDirectory);
|
||||
using var serviceHandle = CreateService(() => feedUrl, cacheDirectory, innerHandler: null);
|
||||
|
||||
var result = await serviceHandle.Service.FetchExtensionsAsync();
|
||||
|
||||
Assert.IsFalse(result.HasError);
|
||||
Assert.AreEqual(1, result.Extensions.Count);
|
||||
|
||||
var extension = result.Extensions[0];
|
||||
Assert.AreEqual("publisher.cmdpal-my-extension", extension.Id);
|
||||
Assert.AreEqual("My Extension", extension.Title);
|
||||
Assert.AreEqual("Does amazing things from the Command Palette.", extension.Description);
|
||||
Assert.AreEqual("Does amazing things.", extension.ShortDescription);
|
||||
Assert.AreEqual("Your Name", extension.Author.Name);
|
||||
Assert.AreEqual("https://example.com", extension.Author.Url);
|
||||
Assert.AreEqual("https://example.com/my-extension", extension.Homepage);
|
||||
CollectionAssert.AreEqual(new List<string> { "cmdpal", "productivity" }, extension.Tags);
|
||||
|
||||
Assert.AreEqual(1, extension.InstallSources.Count);
|
||||
var installSource = extension.InstallSources[0];
|
||||
Assert.AreEqual("jsonrpc", installSource.Type);
|
||||
Assert.IsNotNull(installSource.Npm);
|
||||
Assert.AreEqual("@publisher/cmdpal-my-extension", installSource.Npm!.Package);
|
||||
Assert.AreEqual("1.0.0", installSource.Npm.Version);
|
||||
Assert.AreEqual(
|
||||
"sha512-3sxT2b3Ea2u2vLXA7Yl0dOZH3Rm9j1p3T0i8b9m2wJ0kZ8t2K1cQ0f8p7L6r5S4d3F2a1B0c9D8e7F6g5H4i3J2k1L0m9N8o7P6q5R4s3T2u1V0w==",
|
||||
installSource.Npm.Integrity);
|
||||
Assert.AreEqual("https://registry.npmjs.org", installSource.Npm.Registry);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task FetchExtensionsAsync_ReusesFreshHttpIconCache_WithoutAnotherNetworkCall()
|
||||
{
|
||||
|
||||
@@ -866,6 +866,162 @@ public class JSExtensionManifestTests
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryParse_RelativeIcon_ResolvesToContainedAbsolutePath()
|
||||
{
|
||||
CreateEntryPoint("dist/index.js");
|
||||
CreateEntryPoint("assets/icon.png");
|
||||
const string Json = """
|
||||
{
|
||||
"name": "relative-icon",
|
||||
"main": "dist/index.js",
|
||||
"cmdpal": { "icon": "assets/icon.png" }
|
||||
}
|
||||
""";
|
||||
|
||||
var result = JSExtensionManifest.TryParse(Json, _testDirectory);
|
||||
|
||||
Assert.IsTrue(result.IsValid, result.FailureReason);
|
||||
var expected = Path.GetFullPath(Path.Combine(_testDirectory, "assets", "icon.png"));
|
||||
Assert.AreEqual(expected, result.Manifest!.IconPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryParse_RootDirectory_IsResolvedToPackageRoot()
|
||||
{
|
||||
CreateEntryPoint("dist/index.js");
|
||||
const string Json = """
|
||||
{
|
||||
"name": "root-directory",
|
||||
"main": "dist/index.js",
|
||||
"cmdpal": {}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = JSExtensionManifest.TryParse(Json, _testDirectory);
|
||||
|
||||
Assert.IsTrue(result.IsValid, result.FailureReason);
|
||||
var expected = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_testDirectory));
|
||||
Assert.AreEqual(expected, result.Manifest!.RootDirectory);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryParse_RelativeIcon_ThatEscapesPackage_ResolvesToEmpty()
|
||||
{
|
||||
CreateEntryPoint("dist/index.js");
|
||||
const string Json = """
|
||||
{
|
||||
"name": "escaping-icon",
|
||||
"main": "dist/index.js",
|
||||
"cmdpal": { "icon": "../outside-icon.png" }
|
||||
}
|
||||
""";
|
||||
|
||||
var result = JSExtensionManifest.TryParse(Json, _testDirectory);
|
||||
|
||||
Assert.IsTrue(result.IsValid, result.FailureReason);
|
||||
Assert.AreEqual(string.Empty, result.Manifest!.IconPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryParse_RelativeIcon_ThatDoesNotExist_ResolvesToEmpty()
|
||||
{
|
||||
CreateEntryPoint("dist/index.js");
|
||||
const string Json = """
|
||||
{
|
||||
"name": "missing-icon",
|
||||
"main": "dist/index.js",
|
||||
"cmdpal": { "icon": "assets/missing.png" }
|
||||
}
|
||||
""";
|
||||
|
||||
var result = JSExtensionManifest.TryParse(Json, _testDirectory);
|
||||
|
||||
Assert.IsTrue(result.IsValid, result.FailureReason);
|
||||
Assert.AreEqual(string.Empty, result.Manifest!.IconPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryParse_GlyphIcon_IsPreservedUnchanged()
|
||||
{
|
||||
CreateEntryPoint("dist/index.js");
|
||||
const string Json = """
|
||||
{
|
||||
"name": "glyph-icon",
|
||||
"main": "dist/index.js",
|
||||
"cmdpal": { "icon": "\uE700" }
|
||||
}
|
||||
""";
|
||||
|
||||
var result = JSExtensionManifest.TryParse(Json, _testDirectory);
|
||||
|
||||
Assert.IsTrue(result.IsValid, result.FailureReason);
|
||||
Assert.AreEqual("\uE700", result.Manifest!.IconPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryParse_UriIcon_IsPreservedUnchanged()
|
||||
{
|
||||
CreateEntryPoint("dist/index.js");
|
||||
const string Json = """
|
||||
{
|
||||
"name": "uri-icon",
|
||||
"main": "dist/index.js",
|
||||
"cmdpal": { "icon": "https://example.com/icon.png" }
|
||||
}
|
||||
""";
|
||||
|
||||
var result = JSExtensionManifest.TryParse(Json, _testDirectory);
|
||||
|
||||
Assert.IsTrue(result.IsValid, result.FailureReason);
|
||||
Assert.AreEqual("https://example.com/icon.png", result.Manifest!.IconPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryParse_IconThroughJunction_ResolvesToEmpty()
|
||||
{
|
||||
// An icon whose lexical path stays inside the package but traverses a junction that
|
||||
// redirects outside the package must resolve to empty rather than load the outside file.
|
||||
CreateEntryPoint("dist/index.js");
|
||||
|
||||
var outsideDirectory = Path.Combine(Path.GetTempPath(), $"JSExtensionIconJunctionTarget_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(outsideDirectory);
|
||||
File.WriteAllText(Path.Combine(outsideDirectory, "icon.png"), "// icon bytes");
|
||||
|
||||
var junctionPath = Path.Combine(_testDirectory, "linked-assets");
|
||||
if (!TryCreateJunction(junctionPath, outsideDirectory))
|
||||
{
|
||||
Directory.Delete(outsideDirectory, recursive: true);
|
||||
Assert.Inconclusive("A directory junction could not be created in this environment.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const string Json = """
|
||||
{
|
||||
"name": "junction-icon",
|
||||
"main": "dist/index.js",
|
||||
"cmdpal": { "icon": "linked-assets/icon.png" }
|
||||
}
|
||||
""";
|
||||
|
||||
var result = JSExtensionManifest.TryParse(Json, _testDirectory);
|
||||
|
||||
Assert.IsTrue(result.IsValid, result.FailureReason);
|
||||
Assert.AreEqual(string.Empty, result.Manifest!.IconPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(junctionPath))
|
||||
{
|
||||
Directory.Delete(junctionPath, recursive: false);
|
||||
}
|
||||
|
||||
Directory.Delete(outsideDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryCreateJunction(string junctionPath, string targetPath)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -545,10 +545,9 @@ public class NpmJsExtensionInstallerTests
|
||||
[TestMethod]
|
||||
public async Task InstallAsync_PreservesMixedScopedAndUnscopedDependencies()
|
||||
{
|
||||
// The closure npm ci installs under the package's own node_modules, including @scope
|
||||
// directories, survives promotion. Scoped, unscoped, and mixed dependencies all land in the
|
||||
// promoted extension. The scoped merge discard defect lives only in the phase 6
|
||||
// JsExtensionPackageLayout, which is absent from phase 5.
|
||||
// The publisher-frozen closure npm ci installs under the package's own node_modules, including
|
||||
// @scope directories, survives promotion so scoped, unscoped, and mixed dependencies all land
|
||||
// in the promoted extension.
|
||||
var host = CreateHost();
|
||||
var runner = new FakeRunner
|
||||
{
|
||||
|
||||
252
src/modules/cmdpal/doc/json-rpc-spec/01-architecture.md
Normal file
252
src/modules/cmdpal/doc/json-rpc-spec/01-architecture.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# 01 - Architecture Overview
|
||||
|
||||
## Process Model
|
||||
|
||||
Each JavaScript/TypeScript extension runs as an **isolated Node.js process**. The CmdPal host manages these processes through `JsonRpcExtensionService`, which implements the same `IExtensionService` interface used by WinRT and built-in extensions.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Host["Host Process (CmdPal)"]
|
||||
JSSVC["JsonRpcExtensionService"]
|
||||
WRAP["JSExtensionWrapper"]
|
||||
RPC["JsonRpcConnection"]
|
||||
PROXY["JSCommandProviderProxy"]
|
||||
ONE["(one per extension)"]
|
||||
|
||||
JSSVC --> WRAP --> RPC --> PROXY
|
||||
JSSVC --> ONE
|
||||
end
|
||||
|
||||
subgraph Ext["Extension Process (Node.js)"]
|
||||
SDK["@microsoft/cmdpal-sdk"]
|
||||
STDIO["stdio-server.ts\n(JSON-RPC 2.0)"]
|
||||
CODE["Extension code\nindex.ts"]
|
||||
|
||||
SDK --> STDIO
|
||||
CODE --> STDIO
|
||||
end
|
||||
|
||||
RPC -->|"stdin"| STDIO
|
||||
STDIO -->|"stdout"| RPC
|
||||
```
|
||||
|
||||
### Why Per-Process?
|
||||
|
||||
1. **Crash isolation.** A runaway extension cannot crash CmdPal. If an extension process dies, only that extension stops working.
|
||||
2. **Resource isolation.** Each extension has independent memory, CPU, and event loop.
|
||||
3. **Independent debugging.** Attach a debugger to a specific extension's Node.js process via `--inspect`.
|
||||
4. **Clean lifecycle.** Stop or restart an extension by killing and re-spawning its process.
|
||||
5. **Fault isolation, not a security sandbox.** Separate processes keep one extension from reading another extension's or the host's in-process memory, but this is a crash and fault boundary only. It is not a security or trust boundary: extension code runs with the user's full privileges, so treat every extension like any other Node.js program you choose to run.
|
||||
|
||||
### Crash Recovery
|
||||
|
||||
The host tracks consecutive crashes per extension:
|
||||
|
||||
| Crash Count | Behavior |
|
||||
|-------------|----------|
|
||||
| 1 to 3 | Extension marked as disconnected, available for restart |
|
||||
| > 3 | Extension marked as **unhealthy**, disabled until manual re-enable |
|
||||
|
||||
## Extension Discovery
|
||||
|
||||
Extensions are discovered from a well-known directory:
|
||||
|
||||
```
|
||||
%LOCALAPPDATA%\Microsoft\PowerToys\CmdPal\JSExtensions\
|
||||
```
|
||||
|
||||
Each subdirectory containing a valid `package.json` with a `cmdpal` section is treated as an extension:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
ROOT["JSExtensions/"] --> MY["my-extension/"]
|
||||
ROOT --> ANOTHER["another-extension/"]
|
||||
|
||||
MY --> PKG["package.json\nmanifest with cmdpal section (required)"]
|
||||
MY --> DIST["dist/"]
|
||||
DIST --> JSENTRY["index.js\ncompiled entry point"]
|
||||
MY --> NM["node_modules/"]
|
||||
NM --> MS["@microsoft/cmdpal-sdk"]
|
||||
MY --> SRC["src/"]
|
||||
SRC --> TSENTRY["index.ts\nTypeScript source"]
|
||||
|
||||
ANOTHER --> APKG["package.json"]
|
||||
ANOTHER --> AETC["..."]
|
||||
```
|
||||
|
||||
### Directory Watching
|
||||
|
||||
The service watches the `JSExtensions` directory for:
|
||||
|
||||
| Event | Behavior |
|
||||
|-------|----------|
|
||||
| **New subdirectory created** | Scans for `package.json` with `cmdpal` section, loads extension if valid |
|
||||
| **Subdirectory deleted** | Stops the extension process, removes from provider list |
|
||||
| **`*.js` file changed** (within an extension) | Hot-reloads the extension (debounced 500ms) |
|
||||
|
||||
Source file watchers ignore `node_modules/` changes.
|
||||
|
||||
## Extension Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
DISC["Discover\n(find package.json with cmdpal)"] --> PARSE["Parse Manifest\n(validate manifest)"]
|
||||
PARSE --> START["Start Process\n(spawn node process)"]
|
||||
START --> INIT["Initialize\n(JSON-RPC initialize)"]
|
||||
INIT --> READY["Ready\n(host queries commands)"]
|
||||
READY -->|"on shutdown/hot-reload"| STOP["Stop\n(send dispose, kill process)"]
|
||||
READY -->|"on crash or error"| STOP
|
||||
```
|
||||
|
||||
### Startup Sequence
|
||||
|
||||
1. `JsonRpcExtensionService.LoadProvidersAsync()` scans `JSExtensions/`
|
||||
2. For each valid `package.json` containing a `cmdpal` section:
|
||||
- Creates `JSExtensionWrapper`
|
||||
- Spawns `node <entrypoint>` with stdio redirection
|
||||
- Creates `JsonRpcConnection` over the process's stdin/stdout
|
||||
- Sends `initialize` request and waits for response
|
||||
- Creates `JSCommandProviderProxy` as the `ICommandProvider` implementation
|
||||
- Wraps in `CommandProviderWrapper` and returns to `TopLevelCommandManager`
|
||||
|
||||
### Hot-Reload (Development)
|
||||
|
||||
When a `*.js` file changes in an extension directory:
|
||||
|
||||
1. Change detected by `FileSystemWatcher`
|
||||
2. Debounced 500ms to coalesce rapid saves
|
||||
3. Current process receives `dispose` notification
|
||||
4. Process is killed after 2s grace period
|
||||
5. New process is spawned and initialized
|
||||
6. Crash counter is reset on successful initialization
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Set `"debug": true` in the `cmdpal` section of `package.json` to start the Node.js process with `--inspect`:
|
||||
|
||||
```json
|
||||
{
|
||||
"cmdpal": {
|
||||
"debug": true,
|
||||
"debugPort": 9230
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The host logs a Chrome DevTools URL for attaching:
|
||||
|
||||
```
|
||||
chrome-devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:9230
|
||||
```
|
||||
|
||||
If `debugPort` is not specified, ports are auto-assigned starting at 9229.
|
||||
|
||||
## Transport Layer
|
||||
|
||||
### Framing
|
||||
|
||||
All messages use **LSP-style framing** (Language Server Protocol):
|
||||
|
||||
```http
|
||||
Content-Length: <byte-count>\r\n
|
||||
\r\n
|
||||
<UTF-8 JSON body>
|
||||
```
|
||||
|
||||
This is the same framing used by VS Code's Language Server Protocol, making it compatible with existing JSON-RPC tooling.
|
||||
|
||||
### Connection Details
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Transport | Process stdio (stdin for writes, stdout for reads) |
|
||||
| Encoding | UTF-8 |
|
||||
| Framing | `Content-Length` header (LSP-style) |
|
||||
| Protocol | JSON-RPC 2.0 |
|
||||
| Request timeout | 10 seconds |
|
||||
| Concurrency | Serialized writes (lock-protected), async reads |
|
||||
| Error channel | stderr (logged by host, not part of protocol) |
|
||||
|
||||
### Message Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Host
|
||||
participant Extension
|
||||
|
||||
Host->>Extension: Content-Length: N {"jsonrpc":"2.0","id":1,...}
|
||||
Extension-->>Host: Content-Length: M {"jsonrpc":"2.0","id":1,...}
|
||||
Extension-->>Host: Content-Length: K {"jsonrpc":"2.0","method":...} (no id)
|
||||
```
|
||||
|
||||
## C# Host-Side Architecture
|
||||
|
||||
### Key Classes
|
||||
|
||||
| Class | Role |
|
||||
|-------|------|
|
||||
| `JsonRpcExtensionService` | Discovers, loads, and manages JS extension processes |
|
||||
| `JSExtensionManifest` | Parses and validates `package.json` with `cmdpal` section |
|
||||
| `JSExtensionWrapper` | Manages a single Node.js process lifecycle (implements `IExtensionWrapper`) |
|
||||
| `JsonRpcConnection` | Low-level JSON-RPC 2.0 transport over stdio |
|
||||
| `JSCommandProviderProxy` | Translates `ICommandProvider` interface calls to JSON-RPC requests |
|
||||
| `JSCommandItemAdapter` | Adapts JSON command item data to `ICommandItem` |
|
||||
| `JSInvokableCommandAdapter` | Adapts JSON command data to `IInvokableCommand` and parses invoke results |
|
||||
| `JSListPageProxy` | Adapts JSON list page data to `IListPage` interface |
|
||||
| `JSDynamicListPageProxy` | Adapts a dynamic (search-driven) list page to `IDynamicListPage` |
|
||||
| `JSContentPageProxy` | Adapts JSON content page data to `IContentPage` interface |
|
||||
| `JSModelMapper` | Translates JSON payloads into toolkit data types (icons, tags, details, content, grid layouts, filters) |
|
||||
| `JSCmdPalSection` | Groups list items into a named `ISection` |
|
||||
|
||||
### Adapter Pattern
|
||||
|
||||
The host-side uses an **adapter/proxy pattern** to present JSON-RPC responses as native `ICommand`, `IListPage`, `IContentPage`, and related interfaces. This allows the existing CmdPal UI (built for WinRT extensions) to consume JS extensions without any changes to the UI layer.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI["UI Layer (unchanged)"] --> CPP["JSCommandProviderProxy: ICommandProvider"] --> CONN["JsonRpcConnection"] --> NODE["Node.js"]
|
||||
|
||||
LPP["JSListPageProxy: IListPage"]
|
||||
CPPG["JSContentPageProxy: IContentPage"]
|
||||
CAD["JSCommandItemAdapter: ICommandItem"]
|
||||
MAP["JSModelMapper: icons, tags, details"]
|
||||
|
||||
CPP --> LPP
|
||||
CPP --> CPPG
|
||||
CPP --> CAD
|
||||
CPP --> MAP
|
||||
```
|
||||
|
||||
### Icon Data Pipeline
|
||||
|
||||
JS extensions can provide icons in three formats:
|
||||
|
||||
| Format | `IconData` field | C# handling |
|
||||
|--------|------------------|-------------|
|
||||
| **Font glyph** | `icon: "\uE91B"` | `IconPathConverter.IconSourceMUX` produces a `FontIconSource` |
|
||||
| **File/URI path** | `icon: "C:\\path\\icon.png"` | `IconPathConverter.IconSourceMUX` produces a `BitmapImage` |
|
||||
| **Base64 data** | `data: "iVBOR..."` | `JSModelMapper.ParseIconData` produces an `InMemoryRandomAccessStream` backing a `BitmapImage` |
|
||||
| **Data URI** | `data: "data:image/png;base64,..."` | `JSModelMapper.ParseIconData` parses the data URI, decodes it, and streams the result |
|
||||
|
||||
For base64 images, the TS extension fetches/encodes the image data at runtime. The SDK provides helper functions (`iconFromUrl`, `iconFromFile`, `iconFromBase64`) to simplify this.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Current State (v1)
|
||||
|
||||
JS extensions run as **unsandboxed Node.js processes** with the same permissions as the user running CmdPal. This is equivalent to running any Node.js application.
|
||||
|
||||
### Mitigations
|
||||
|
||||
These reduce the blast radius of a misbehaving extension; they are not a security sandbox, because a gallery or sideloaded extension already runs with the user's full privileges.
|
||||
|
||||
- **Process separation.** One extension's process cannot read another extension's or the host's in-process memory. This is a fault and crash boundary, not a trust boundary.
|
||||
- **No elevated privileges.** Extensions run at the user's permission level, never elevated.
|
||||
- **Crash containment.** Runaway extensions are auto-disabled after 3 consecutive crashes.
|
||||
- **No network exposure.** Communication is via stdio, not network sockets.
|
||||
|
||||
### Future Considerations
|
||||
|
||||
- Permission model for filesystem, network, and clipboard access
|
||||
- Extension signing and trust verification
|
||||
- Sandboxed execution environments (e.g., V8 isolates)
|
||||
679
src/modules/cmdpal/doc/json-rpc-spec/02-typescript-sdk.md
Normal file
679
src/modules/cmdpal/doc/json-rpc-spec/02-typescript-sdk.md
Normal file
@@ -0,0 +1,679 @@
|
||||
# 02 - TypeScript SDK Reference
|
||||
|
||||
> **Package:** `@microsoft/cmdpal-sdk`
|
||||
> **Version:** 0.1.0
|
||||
> **Node.js:** ≥ 22.0.0
|
||||
> **TypeScript:** ≥ 5.8
|
||||
|
||||
## Installation
|
||||
|
||||
The SDK is not published to npm yet, so you consume it from this repository rather than from the registry. Build the SDK once so its `dist` output exists:
|
||||
|
||||
```bash
|
||||
cd src/modules/cmdpal/ts-sdk
|
||||
npm ci && npm run build
|
||||
```
|
||||
|
||||
Then reference it from your extension through a relative `file:` dependency that points at your checkout of `src/modules/cmdpal/ts-sdk`. Adjust the relative path to match where your extension lives:
|
||||
|
||||
```bash
|
||||
npm install "@microsoft/cmdpal-sdk@file:../path/to/src/modules/cmdpal/ts-sdk"
|
||||
```
|
||||
|
||||
Repository samples use the same mechanism, referencing the SDK with `"@microsoft/cmdpal-sdk": "file:../../ts-sdk"` because they sit two folders below `ts-sdk`. Once the package is published to npm, a versioned `npm install @microsoft/cmdpal-sdk` will replace the `file:` dependency.
|
||||
|
||||
The shipped package is ESM (`"type": "module"`) and exposes `./dist/index.js` with type declarations from `./dist/index.d.ts`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Core Types
|
||||
|
||||
### Icon Types
|
||||
|
||||
```typescript
|
||||
interface IconData {
|
||||
icon?: string; // Font glyph character or file/URI path
|
||||
data?: string | null; // Base64-encoded image data or data URI
|
||||
}
|
||||
|
||||
interface IconInfo {
|
||||
light?: IconData; // Icon for light theme
|
||||
dark?: IconData; // Icon for dark theme
|
||||
}
|
||||
```
|
||||
|
||||
Icons can be provided as:
|
||||
- **Font glyphs:** `{ icon: '\uE91B' }`, for Segoe Fluent Icons / MDL2 Assets
|
||||
- **File paths:** `{ icon: 'C:\\path\\to\\icon.png' }`
|
||||
- **Base64 data:** `{ data: 'iVBORw0KGgo...' }`, with raw base64-encoded image bytes
|
||||
- **Data URIs:** `{ data: 'data:image/png;base64,iVBOR...' }`
|
||||
|
||||
### Color Types
|
||||
|
||||
```typescript
|
||||
interface Color {
|
||||
r: number; // 0-255
|
||||
g: number;
|
||||
b: number;
|
||||
a: number; // 0-255 (default: 255)
|
||||
}
|
||||
|
||||
interface OptionalColor {
|
||||
hasValue: boolean;
|
||||
color?: Color;
|
||||
}
|
||||
```
|
||||
|
||||
### Tags
|
||||
|
||||
```typescript
|
||||
interface Tag {
|
||||
icon?: IconInfo | null;
|
||||
text: string;
|
||||
foreground?: OptionalColor | null;
|
||||
background?: OptionalColor | null;
|
||||
toolTip?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Key Chords
|
||||
|
||||
```typescript
|
||||
interface KeyChord {
|
||||
modifiers: number; // Bitmask: Ctrl=1, Alt=2, Shift=4, Win=8
|
||||
vkey: number; // Virtual key code
|
||||
scanCode: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Command Types
|
||||
|
||||
### ICommand
|
||||
|
||||
The base contract for all commands and pages.
|
||||
|
||||
```typescript
|
||||
interface ICommand {
|
||||
id: string; // Unique identifier
|
||||
name: string; // Display name
|
||||
icon?: IconInfo | null; // Optional icon
|
||||
}
|
||||
```
|
||||
|
||||
### IInvokableCommand
|
||||
|
||||
A command that can be executed.
|
||||
|
||||
```typescript
|
||||
interface IInvokableCommand extends ICommand {
|
||||
invoke(): Promise<CommandResult> | CommandResult;
|
||||
}
|
||||
```
|
||||
|
||||
### CommandResult
|
||||
|
||||
Returned from `invoke()` to tell the host what to do next.
|
||||
|
||||
```typescript
|
||||
type CommandResultKind =
|
||||
| 'dismiss' // Close CmdPal
|
||||
| 'goHome' // Navigate to home
|
||||
| 'goBack' // Navigate back
|
||||
| 'hide' // Hide CmdPal (keep state)
|
||||
| 'keepOpen' // Stay on current page
|
||||
| 'goToPage' // Navigate to a page
|
||||
| 'showToast' // Show toast notification
|
||||
| 'confirm'; // Show confirmation dialog
|
||||
|
||||
interface CommandResult {
|
||||
kind: CommandResultKind;
|
||||
args?: CommandResultArgs;
|
||||
}
|
||||
```
|
||||
|
||||
### Result Args
|
||||
|
||||
```typescript
|
||||
interface GoToPageArgs {
|
||||
pageId: string;
|
||||
navigationMode?: 'push' | 'goBack' | 'goHome';
|
||||
}
|
||||
|
||||
interface ToastArgs {
|
||||
message: string;
|
||||
result?: CommandResult; // What to do after toast is dismissed
|
||||
}
|
||||
|
||||
interface ConfirmationArgs {
|
||||
title: string;
|
||||
description: string;
|
||||
primaryCommand?: ICommand;
|
||||
isPrimaryCommandCritical?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Helper: Creating Results
|
||||
|
||||
```typescript
|
||||
// Navigate to a page
|
||||
{ kind: 'goToPage', args: { pageId: 'my-page', navigationMode: 'push' } }
|
||||
|
||||
// Show a toast
|
||||
{ kind: 'showToast', args: { message: 'Done!' } }
|
||||
|
||||
// Confirmation dialog
|
||||
{ kind: 'confirm', args: { title: 'Delete?', description: 'This cannot be undone.' } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Item Types
|
||||
|
||||
### ICommandItem
|
||||
|
||||
A selectable item shown in lists.
|
||||
|
||||
```typescript
|
||||
interface ICommandItem {
|
||||
command: ICommand;
|
||||
moreCommands?: ContextItem[]; // Right-click / overflow menu
|
||||
icon?: IconInfo | null;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### IListItem
|
||||
|
||||
Extended list item with metadata.
|
||||
|
||||
```typescript
|
||||
interface IListItem extends ICommandItem {
|
||||
tags?: Tag[];
|
||||
details?: Details;
|
||||
section?: string; // See note below: only labels a command-less header row
|
||||
textToSuggest?: string; // Text to fill into search box on selection
|
||||
}
|
||||
```
|
||||
|
||||
> **Grouping note:** `section` does **not** group a command-bearing item. The host
|
||||
> renders a list item that has a command as a normal item and ignores its `section`.
|
||||
> `section` takes effect only on an item that has no command, where it turns that row
|
||||
> into a section header. To visually group command items, insert a standalone
|
||||
> [`Separator('Title')`](#separator) row before the group instead of setting `section`
|
||||
> on the items.
|
||||
|
||||
### IFallbackCommandItem
|
||||
|
||||
A command that receives the user's search query in real-time.
|
||||
|
||||
```typescript
|
||||
interface IFallbackCommandItem extends ICommandItem {
|
||||
fallbackHandler?: IFallbackHandler;
|
||||
displayTitle?: string; // Dynamic title that updates as user types
|
||||
}
|
||||
|
||||
interface IFallbackHandler {
|
||||
updateQuery(query: string): void | Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### ContextItem
|
||||
|
||||
An action in a right-click or overflow menu. A context item can itself carry `moreCommands`, so context menus can nest into sub-menus.
|
||||
|
||||
```typescript
|
||||
interface ContextItem {
|
||||
command: ICommand;
|
||||
moreCommands?: ContextItem[]; // Nested sub-menu of further context actions
|
||||
icon?: IconInfo | null;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
isCritical?: boolean; // Show in red/destructive style
|
||||
requestedShortcut?: KeyChord; // Keyboard shortcut hint
|
||||
}
|
||||
```
|
||||
|
||||
### Separator
|
||||
|
||||
A standalone row that divides a list. With a title it renders as a section header,
|
||||
which is the supported way to group command items.
|
||||
|
||||
```typescript
|
||||
import { Separator } from '@microsoft/cmdpal-sdk';
|
||||
|
||||
// Untitled separator (horizontal line)
|
||||
new Separator()
|
||||
|
||||
// Section header separator
|
||||
new Separator('Section Title')
|
||||
```
|
||||
|
||||
To group items, return `Separator('Title')` rows interleaved with your items. Each
|
||||
separator begins a new visual group that runs until the next separator:
|
||||
|
||||
```typescript
|
||||
getItems(): (IListItem | Separator)[] {
|
||||
return [
|
||||
new Separator('Recent'),
|
||||
recentItemA,
|
||||
recentItemB,
|
||||
new Separator('All commands'),
|
||||
commandA,
|
||||
commandB,
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Setting `section` on the command items themselves does not group them; the host only
|
||||
uses `Separator` rows for grouping (see the [grouping note](#ilistitem) above).
|
||||
|
||||
---
|
||||
|
||||
## Details Panel
|
||||
|
||||
Rich metadata shown alongside a selected list item.
|
||||
|
||||
```typescript
|
||||
interface Details {
|
||||
heroImage?: IconInfo | null;
|
||||
title?: string;
|
||||
body?: string; // Markdown-formatted body text
|
||||
metadata?: DetailsElement[];
|
||||
}
|
||||
|
||||
interface DetailsElement {
|
||||
key: string; // Label shown to the left
|
||||
data: DetailsData; // Value shown to the right
|
||||
}
|
||||
|
||||
// Discriminated union of detail data types
|
||||
type DetailsData =
|
||||
| DetailsTags // { type: 'tags', tags: Tag[] }
|
||||
| DetailsLink // { type: 'link', link: string, text: string }
|
||||
| DetailsCommands // { type: 'commands', commands: ICommand[] }
|
||||
| DetailsSeparator; // { type: 'separator' }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Page Types
|
||||
|
||||
### IListPage
|
||||
|
||||
A page that shows a scrollable list of items.
|
||||
|
||||
```typescript
|
||||
interface IListPage extends IPage {
|
||||
searchText?: string;
|
||||
placeholderText?: string;
|
||||
showDetails?: boolean; // Show details panel
|
||||
filters?: Filters | null; // Filter bar
|
||||
gridProperties?: GridProperties | null; // Grid/gallery layout
|
||||
hasMoreItems?: boolean; // Infinite scroll
|
||||
emptyContent?: ICommandItem | null;
|
||||
getItems(): IListItem[] | Promise<IListItem[]>;
|
||||
loadMore?(): void | Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### IDynamicListPage
|
||||
|
||||
A list page that receives search input in real-time.
|
||||
|
||||
```typescript
|
||||
interface IDynamicListPage extends IListPage {
|
||||
setSearchText(text: string): void | Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### IContentPage
|
||||
|
||||
A page that displays rich content (markdown, forms, images, trees).
|
||||
|
||||
```typescript
|
||||
interface IContentPage extends IPage {
|
||||
getContent(): Content[] | Promise<Content[]>;
|
||||
details?: Details | null;
|
||||
commands?: ContextItem[];
|
||||
}
|
||||
```
|
||||
|
||||
### Filters
|
||||
|
||||
```typescript
|
||||
interface Filter {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: IconInfo | null;
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
currentFilterId: string;
|
||||
filters: Array<Filter | { separator: true }>;
|
||||
}
|
||||
```
|
||||
|
||||
### Grid Properties
|
||||
|
||||
```typescript
|
||||
type GridLayoutType = 'small' | 'medium' | 'gallery';
|
||||
|
||||
interface GridProperties {
|
||||
type: GridLayoutType;
|
||||
showTitle?: boolean;
|
||||
showSubtitle?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Content Types
|
||||
|
||||
Content pages display an array of `Content` items:
|
||||
|
||||
```typescript
|
||||
type ContentType = 'markdown' | 'form' | 'tree' | 'plainText' | 'image';
|
||||
|
||||
interface MarkdownContent {
|
||||
type: 'markdown';
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface FormContent {
|
||||
type: 'form';
|
||||
formId?: string; // Stable id for routing form/submit; see note below
|
||||
templateJson: string; // Adaptive Card JSON template
|
||||
dataJson: string; // Form data values JSON
|
||||
stateJson?: string;
|
||||
submitForm(inputs: string, data: string): CommandResult | Promise<CommandResult>;
|
||||
}
|
||||
|
||||
interface ImageContent {
|
||||
type: 'image';
|
||||
image: IconInfo; // Base64-encoded image data (use iconFromUrl/iconFromFile)
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
interface PlainTextContent {
|
||||
type: 'plainText';
|
||||
text: string;
|
||||
fontFamily?: 'userInterface' | 'monospace';
|
||||
wrapWords?: boolean;
|
||||
}
|
||||
|
||||
interface TreeContent {
|
||||
type: 'tree';
|
||||
rootContent: Content;
|
||||
getChildren(): Content[] | Promise<Content[]>;
|
||||
}
|
||||
```
|
||||
|
||||
> **`formId` note:** The SDK gives every form a stable id and routes an incoming
|
||||
> `form/submit` back to that form's `submitForm` handler by `(pageId, formId)`. When
|
||||
> you omit `formId`, the serializer assigns one positionally in traversal order. That
|
||||
> positional fallback is fine for a page with a fixed set of top-level forms, but it
|
||||
> can drift for a form nested in a `tree` whose children are produced lazily or whose
|
||||
> shape changes between serializations (for example, a comment thread that gains a
|
||||
> reply). Set an explicit, stable `formId` on any nested or dynamically produced form
|
||||
> so its submissions always route correctly.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
```typescript
|
||||
import { Settings, ToggleSetting, TextSetting, ChoiceSetSetting } from '@microsoft/cmdpal-sdk';
|
||||
|
||||
const settings = new Settings();
|
||||
|
||||
// Toggle (boolean)
|
||||
settings.add(new ToggleSetting('darkMode', 'Dark Mode', true, 'Enable dark theme'));
|
||||
|
||||
// Text input
|
||||
settings.add(new TextSetting('apiKey', 'API Key', '', 'Your API key'));
|
||||
|
||||
// Choice set (dropdown)
|
||||
settings.add(new ChoiceSetSetting('language', 'Language', [
|
||||
{ title: 'English', value: 'en' },
|
||||
{ title: 'Spanish', value: 'es' },
|
||||
], 'en'));
|
||||
|
||||
// Read values
|
||||
const darkMode = settings.getSetting<ToggleSetting>('darkMode')?.value;
|
||||
|
||||
// Expose in provider
|
||||
class MyProvider extends CommandProviderBase {
|
||||
settings = settings;
|
||||
// ... settings page auto-generated from settings definitions
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Base Classes
|
||||
|
||||
### CommandProviderBase
|
||||
|
||||
The entry point for every extension.
|
||||
|
||||
```typescript
|
||||
class MyProvider extends CommandProviderBase implements ICommandProvider {
|
||||
readonly id = 'my-extension';
|
||||
readonly displayName = 'My Extension';
|
||||
readonly icon = iconFromGlyph('\uE8A5');
|
||||
|
||||
topLevelCommands(): ICommandItem[] | Promise<ICommandItem[]> {
|
||||
return [ /* ... */ ];
|
||||
}
|
||||
|
||||
fallbackCommands(): IFallbackCommandItem[] | Promise<IFallbackCommandItem[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
getCommand(id: string): ICommand | null | Promise<ICommand | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
settings?: ICommandSettings | null;
|
||||
|
||||
initializeWithHost(host: IExtensionHost): void {
|
||||
// Store the host if this provider needs it.
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
// Release resources before shutdown.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ListPageBase
|
||||
|
||||
```typescript
|
||||
class MyListPage extends ListPageBase implements IListPage {
|
||||
readonly id = 'my-list';
|
||||
readonly name = 'My List';
|
||||
readonly title = 'My List Page';
|
||||
|
||||
getItems(): IListItem[] {
|
||||
return [ /* ... */ ];
|
||||
}
|
||||
|
||||
// Optional
|
||||
showDetails?: boolean;
|
||||
filters?: Filters | null;
|
||||
gridProperties?: GridProperties | null;
|
||||
placeholderText?: string;
|
||||
loadMore(): void | Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### DynamicListPageBase
|
||||
|
||||
```typescript
|
||||
class MySearchPage extends DynamicListPageBase implements IDynamicListPage {
|
||||
readonly id = 'my-search';
|
||||
readonly name = 'Search';
|
||||
readonly title = 'Search Page';
|
||||
private query = '';
|
||||
|
||||
setSearchText(text: string): void {
|
||||
this.query = text;
|
||||
this.notifyItemsChanged(); // Tell host to re-fetch items
|
||||
}
|
||||
|
||||
getItems(): IListItem[] {
|
||||
return allItems.filter(item => item.title.includes(this.query));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ContentPageBase
|
||||
|
||||
```typescript
|
||||
class MyContentPage extends ContentPageBase implements IContentPage {
|
||||
readonly id = 'my-content';
|
||||
readonly name = 'Content';
|
||||
readonly title = 'My Content Page';
|
||||
|
||||
getContent(): Content[] {
|
||||
return [
|
||||
{ type: 'markdown', body: '# Hello\n\nThis is **markdown** content.' },
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### InvokableCommandBase
|
||||
|
||||
```typescript
|
||||
class MyCommand extends InvokableCommandBase implements IInvokableCommand {
|
||||
readonly id = 'my-command';
|
||||
readonly name = 'Do Something';
|
||||
|
||||
invoke(): CommandResult {
|
||||
// Do work...
|
||||
return { kind: 'showToast', args: { message: 'Done!' } };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Built-In Commands
|
||||
|
||||
| Class | Purpose | `invoke()` returns |
|
||||
|-------|---------|-------------------|
|
||||
| `NoOpCommand` | Does nothing | `{ kind: 'keepOpen' }` |
|
||||
| `OpenUrlCommand` | Opens a URL in the default browser | `{ kind: 'dismiss' }` |
|
||||
| `CopyTextCommand` | Copies text to clipboard | Toast with copy confirmation |
|
||||
| `ConfirmableCommand` | Shows a confirmation dialog | `{ kind: 'confirm', args: {...} }` |
|
||||
|
||||
---
|
||||
|
||||
## Icon Helpers
|
||||
|
||||
```typescript
|
||||
import { iconFromGlyph, iconFromBase64, iconFromUrl, iconFromFile } from '@microsoft/cmdpal-sdk';
|
||||
|
||||
// Font glyph (Segoe Fluent Icons)
|
||||
const icon = iconFromGlyph('\uE91B');
|
||||
|
||||
// Base64-encoded image data
|
||||
const icon = iconFromBase64('iVBORw0KGgoAAAANSUhEUg...');
|
||||
|
||||
// Fetch image from URL (async, downloads and encodes as base64)
|
||||
const icon = await iconFromUrl('https://example.com/icon.png');
|
||||
|
||||
// Read local file (async, reads and encodes as base64)
|
||||
const icon = await iconFromFile('./assets/icon.png');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Runtime API
|
||||
|
||||
### ExtensionHost
|
||||
|
||||
Static bridge for communicating with the CmdPal host.
|
||||
|
||||
```typescript
|
||||
import { ExtensionHost } from '@microsoft/cmdpal-sdk';
|
||||
|
||||
// Logging
|
||||
ExtensionHost.log('Something happened');
|
||||
ExtensionHost.log('Error occurred', 'error');
|
||||
|
||||
// Status bar
|
||||
// showStatus returns a stable status id. Keep it to update or hide that exact
|
||||
// status later, rather than matching on the message text.
|
||||
const statusId = ExtensionHost.showStatus('Loading...', 'info', { isIndeterminate: true });
|
||||
// Replace the working status in place once the work is done...
|
||||
ExtensionHost.updateStatus(statusId, 'Done', 'success');
|
||||
// ...then clear it by id so the spinner is not left behind.
|
||||
ExtensionHost.hideStatus(statusId);
|
||||
|
||||
// Clipboard
|
||||
ExtensionHost.copyToClipboard('Hello, clipboard!');
|
||||
```
|
||||
|
||||
### Activation
|
||||
|
||||
```typescript
|
||||
import { activate, run, startJsonRpcServer, type ActivationContext, type ProviderFactory } from '@microsoft/cmdpal-sdk';
|
||||
|
||||
const factory: ProviderFactory = () => new MyProvider();
|
||||
|
||||
// Standard activation pattern
|
||||
startJsonRpcServer(factory);
|
||||
|
||||
// Alias for startJsonRpcServer
|
||||
run(factory);
|
||||
|
||||
// Activation helper
|
||||
const provider = activate({ extensionId: 'my-extension', extensionDirectory: process.cwd() } satisfies ActivationContext, factory);
|
||||
```
|
||||
|
||||
### Notifications
|
||||
|
||||
```typescript
|
||||
import { sendNotification } from '@microsoft/cmdpal-sdk';
|
||||
|
||||
// Tell the host that a list page's items have changed
|
||||
sendNotification('listPage/itemsChanged', { pageId: 'my-list' });
|
||||
|
||||
// Tell the host that a command's properties changed
|
||||
sendNotification('command/propChanged', {
|
||||
commandId: 'my-fallback',
|
||||
properties: { displayTitle: 'Search: query text' },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## C# Toolkit Equivalence
|
||||
|
||||
| C# Toolkit | TypeScript SDK |
|
||||
|------------|----------------|
|
||||
| `ListPage` | `ListPageBase` |
|
||||
| `DynamicListPage` | `DynamicListPageBase` |
|
||||
| `ContentPage` | `ContentPageBase` |
|
||||
| `InvokableCommand` | `InvokableCommandBase` |
|
||||
| `CommandItem` | `CommandItemBase` |
|
||||
| `ListItem` | `ListItemBase` |
|
||||
| `FallbackCommandItem` | `FallbackCommandItemBase` |
|
||||
| `Separator` | `Separator` |
|
||||
| `NoOpCommand` | `NoOpCommand` |
|
||||
| `OpenUrlCommand` | `OpenUrlCommand` |
|
||||
| `CopyTextCommand` | `CopyTextCommand` |
|
||||
| `ConfirmableCommand` | `ConfirmableCommand` |
|
||||
| `Settings`/`SettingsPage` | `Settings` (auto-generates `IContentPage`) |
|
||||
| `ToggleSetting` | `ToggleSetting` |
|
||||
| `TextSetting` | `TextSetting` |
|
||||
| `ChoiceSetSetting` | `ChoiceSetSetting` |
|
||||
| `IconHelpers.FromRelativePath` | `iconFromFile` |
|
||||
| `IconInfo.FromStream` | `iconFromBase64` / `iconFromUrl` |
|
||||
549
src/modules/cmdpal/doc/json-rpc-spec/03-jsonrpc-protocol.md
Normal file
549
src/modules/cmdpal/doc/json-rpc-spec/03-jsonrpc-protocol.md
Normal file
@@ -0,0 +1,549 @@
|
||||
# 03 - JSON-RPC Protocol Specification
|
||||
|
||||
## Overview
|
||||
|
||||
Communication between the CmdPal host and JavaScript extensions uses **JSON-RPC 2.0** over **stdio** with **LSP-style `Content-Length` framing**.
|
||||
|
||||
### Framing Format
|
||||
|
||||
Every message (request, response, and notification) is preceded by a header:
|
||||
|
||||
```http
|
||||
Content-Length: <byte-count>\r\n
|
||||
\r\n
|
||||
<UTF-8 JSON body>
|
||||
```
|
||||
|
||||
Where `<byte-count>` is the byte length (not character length) of the JSON body.
|
||||
|
||||
### Connection Properties
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Transport | stdin (host→extension), stdout (extension→host) |
|
||||
| Encoding | UTF-8 |
|
||||
| Protocol | JSON-RPC 2.0 |
|
||||
| Request timeout | 10 seconds |
|
||||
| Concurrency | Requests are serialized (one at a time); notifications can interleave |
|
||||
|
||||
### Message Types
|
||||
|
||||
| Type | Has `id` | Has `method` | Direction |
|
||||
|------|----------|-------------|-----------|
|
||||
| Request | ✅ | ✅ | Host → Extension |
|
||||
| Response | ✅ | ❌ | Extension → Host |
|
||||
| Notification | ❌ | ✅ | Either direction |
|
||||
|
||||
---
|
||||
|
||||
## Host → Extension Requests
|
||||
|
||||
### `initialize`
|
||||
|
||||
Called once after the Node.js process starts. The extension should initialize its provider and return capabilities.
|
||||
|
||||
**Parameters:**
|
||||
```json
|
||||
{
|
||||
"extensionId": "my-extension"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"capabilities": ["commands"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `provider/getTopLevelCommands`
|
||||
|
||||
Fetches the extension's top-level command items (shown in the main CmdPal list).
|
||||
|
||||
**Parameters:** `null`
|
||||
|
||||
**Response:** Array of command items:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "cmd-1",
|
||||
"title": "My Command",
|
||||
"displayName": "My Command",
|
||||
"subtitle": "Does something useful",
|
||||
"command": {
|
||||
"id": "cmd-1",
|
||||
"name": "My Command",
|
||||
"icon": { "light": { "icon": "\uE8A5" } },
|
||||
"pageType": "dynamicListPage"
|
||||
},
|
||||
"icon": { "light": { "icon": "\uE8A5" } },
|
||||
"moreCommands": []
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The `command` object includes a `pageType` field when the command represents a page. Values are:
|
||||
- `"listPage"`: static list page
|
||||
- `"dynamicListPage"`: search-enabled list page
|
||||
- `"contentPage"`: rich content page
|
||||
- Absent: invokable command (no page)
|
||||
|
||||
---
|
||||
|
||||
### `provider/getFallbackCommands`
|
||||
|
||||
Fetches commands that receive the user's search query when no other results match.
|
||||
|
||||
**Parameters:** `null`
|
||||
|
||||
**Response:** Array of fallback command items, or `null`:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "search-web",
|
||||
"title": "Search the web",
|
||||
"displayName": "Search the web",
|
||||
"command": { "id": "search-web", "name": "Search the web" }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `provider/getCommand`
|
||||
|
||||
Fetches a specific command/page by ID. Used when navigating to a page.
|
||||
|
||||
**Parameters:**
|
||||
```json
|
||||
{
|
||||
"commandId": "my-page-id"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Command object or `null`:
|
||||
```json
|
||||
{
|
||||
"id": "my-page-id",
|
||||
"name": "My Page",
|
||||
"pageType": "listPage",
|
||||
"icon": { "light": { "icon": "\uE8A5" } }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `provider/getSettings`
|
||||
|
||||
Fetches the extension's settings page ID.
|
||||
|
||||
**Parameters:** `null`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "settings-page-id"
|
||||
}
|
||||
```
|
||||
|
||||
Or `null` if the extension has no settings.
|
||||
|
||||
---
|
||||
|
||||
### `command/invoke`
|
||||
|
||||
Invokes a command by ID.
|
||||
|
||||
**Parameters:**
|
||||
```json
|
||||
{
|
||||
"commandId": "my-command-id"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Command result. The TypeScript SDK returns `{ kind, args? }`, and the runtime serializes that object to the numeric wire shape shown here.
|
||||
```json
|
||||
{
|
||||
"Kind": 6,
|
||||
"Args": {
|
||||
"Message": "Operation complete!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Kind` values:
|
||||
| Value | Name | Description |
|
||||
|-------|------|-------------|
|
||||
| 0 | Dismiss | Close CmdPal |
|
||||
| 1 | GoHome | Navigate to home |
|
||||
| 2 | GoBack | Navigate back |
|
||||
| 3 | Hide | Hide CmdPal (keep state) |
|
||||
| 4 | KeepOpen | Stay on current page |
|
||||
| 5 | GoToPage | Navigate to page (requires `PageId` in args) |
|
||||
| 6 | ShowToast | Show toast notification (requires `Message` in args) |
|
||||
| 7 | Confirm | Show confirmation dialog |
|
||||
|
||||
---
|
||||
|
||||
### `listPage/getItems`
|
||||
|
||||
Fetches items for a list page.
|
||||
|
||||
**Parameters:**
|
||||
```json
|
||||
{
|
||||
"pageId": "my-list-page"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "item-1",
|
||||
"title": "Item One",
|
||||
"subtitle": "Description",
|
||||
"command": { "id": "item-1-cmd", "name": "Item One" },
|
||||
"icon": { "light": { "icon": "\uE8A5" } },
|
||||
"tags": [{ "text": "New", "foreground": { "hasValue": true, "color": { "r": 255, "g": 255, "b": 255, "a": 255 } } }],
|
||||
"details": {
|
||||
"title": "Item One Details",
|
||||
"body": "**Rich** markdown description",
|
||||
"metadata": [
|
||||
{ "key": "Author", "data": { "type": "tags", "tags": [{ "text": "mjolley" }] } },
|
||||
{ "key": "Link", "data": { "type": "link", "link": "https://github.com", "text": "GitHub" } }
|
||||
]
|
||||
},
|
||||
"moreCommands": [
|
||||
{
|
||||
"command": { "id": "copy-cmd", "name": "Copy" },
|
||||
"title": "Copy to clipboard",
|
||||
"icon": { "light": { "icon": "\uE8C8" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"hasMoreItems": false
|
||||
}
|
||||
```
|
||||
|
||||
`hasMoreItems` is a boolean on the response envelope (it defaults to `false` when the extension omits it). `true` tells the host that more pages remain, so the host may issue a [`listPage/loadMore`](#listpageloadmore) request when the user scrolls to the end; `false` means the current items are the full set. The value comes straight from the list page's `hasMoreItems` property.
|
||||
|
||||
The `section` field is ignored on any item that carries a command. The host renders a command-bearing item as a normal list item, so it never becomes a group header. `section` takes effect only on a command-less row, where it turns that row into a section header. To group command items visually, emit a standalone separator row (see below) before the group.
|
||||
|
||||
Items with `_isSeparator: true` are rendered as visual separators:
|
||||
```json
|
||||
{
|
||||
"title": "Section Header",
|
||||
"section": "Section Header",
|
||||
"_isSeparator": true,
|
||||
"command": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `listPage/setSearchText`
|
||||
|
||||
Updates the search text for a dynamic list page.
|
||||
|
||||
**Parameters:**
|
||||
```json
|
||||
{
|
||||
"pageId": "my-dynamic-page",
|
||||
"searchText": "user query"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `null`
|
||||
|
||||
The extension should update its internal state and send a `listPage/itemsChanged` notification when items are ready.
|
||||
|
||||
---
|
||||
|
||||
### `listPage/setFilter`
|
||||
|
||||
Updates the active filter for a list page.
|
||||
|
||||
**Parameters:**
|
||||
```json
|
||||
{
|
||||
"pageId": "my-filtered-page",
|
||||
"filterId": "recent"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `null`
|
||||
|
||||
---
|
||||
|
||||
### `listPage/loadMore`
|
||||
|
||||
Requests additional items for infinite scroll.
|
||||
|
||||
**Parameters:**
|
||||
```json
|
||||
{
|
||||
"pageId": "my-page"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Same envelope as [`listPage/getItems`](#listpagegetitems), reflecting the page after the load:
|
||||
```json
|
||||
{
|
||||
"items": [ /* the page's current items, including the appended page */ ],
|
||||
"hasMoreItems": true
|
||||
}
|
||||
```
|
||||
|
||||
The extension appends the next page to its items and returns the full, re-serialized item list along with `hasMoreItems`. A `hasMoreItems` of `false` (or an omitted flag) tells the host the extension has delivered its final page, so no further `listPage/loadMore` is issued. Extensions that instead mutate items out of band may also send a `listPage/itemsChanged` notification.
|
||||
|
||||
---
|
||||
|
||||
### `fallback/updateQuery`
|
||||
|
||||
Updates the search query for a fallback command. The shipped C# host sends this method as a notification; the SDK also accepts it as a request and returns `null`.
|
||||
|
||||
**Parameters:**
|
||||
```json
|
||||
{
|
||||
"commandId": "search-fallback",
|
||||
"query": "user typed text"
|
||||
}
|
||||
```
|
||||
|
||||
**Response when sent as a request:** `null`
|
||||
|
||||
The extension should update its internal state and send a `command/propChanged` notification to update the display title.
|
||||
|
||||
---
|
||||
|
||||
### `contentPage/getContent`
|
||||
|
||||
Fetches content for a content page.
|
||||
|
||||
**Parameters:**
|
||||
```json
|
||||
{
|
||||
"pageId": "my-content-page"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Array of content items:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"type": "markdown",
|
||||
"body": "# Hello\n\nMarkdown content"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"image": { "light": { "data": "iVBORw0KGgo..." } },
|
||||
"maxWidth": 600,
|
||||
"maxHeight": 400
|
||||
},
|
||||
{
|
||||
"type": "form",
|
||||
"formId": "reply-form",
|
||||
"templateJson": "{...adaptive card JSON...}",
|
||||
"dataJson": "{...data values...}"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `form/submit`
|
||||
|
||||
Submits form data from a content page or form content.
|
||||
|
||||
**Parameters:**
|
||||
```json
|
||||
{
|
||||
"pageId": "my-form-page",
|
||||
"formId": "reply-form",
|
||||
"inputs": "{\"name\":\"John\"}",
|
||||
"data": "{}"
|
||||
}
|
||||
```
|
||||
|
||||
`formId` identifies the specific form to submit and is required whenever a page can serialize more than one form (for example multiple forms on a content page, or forms nested inside a `tree`). Each serialized `form` content block carries a `formId`: the extension author's explicit value when set, otherwise a stable id the serializer assigns in traversal order. The host echoes that `formId` back on submit, and the extension routes the submission to the matching form by `(pageId, formId)`. When `formId` is omitted (a host that does not yet send it), the extension falls back to the first form on the page, so single-form pages keep working without it.
|
||||
|
||||
**Response:** Command result (same format as `command/invoke`).
|
||||
|
||||
---
|
||||
|
||||
### `dispose`
|
||||
|
||||
Notification sent before the host kills the extension process.
|
||||
|
||||
**Parameters:** `null`
|
||||
|
||||
**Note:** This is a notification (no `id`), not a request. The extension should clean up resources.
|
||||
|
||||
---
|
||||
|
||||
## Extension → Host Notifications
|
||||
|
||||
### `provider/itemsChanged`
|
||||
|
||||
Tells the host to re-fetch the provider's top-level or fallback command items.
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "provider/itemsChanged",
|
||||
"params": {
|
||||
"totalItems": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `listPage/itemsChanged`
|
||||
|
||||
Tells the host to re-fetch items for a list page.
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "listPage/itemsChanged",
|
||||
"params": {
|
||||
"pageId": "my-list-page"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `command/propChanged`
|
||||
|
||||
Tells the host that a command's properties have changed (e.g., fallback display title).
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "command/propChanged",
|
||||
"params": {
|
||||
"commandId": "my-fallback",
|
||||
"properties": {
|
||||
"displayTitle": "Search: new query"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `host/logMessage`
|
||||
|
||||
Sends a log message to the host's logging system.
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "host/logMessage",
|
||||
"params": {
|
||||
"message": "Extension initialized successfully",
|
||||
"state": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
State values: 0 = Info, 1 = Success, 2 = Warning, 3 = Error
|
||||
|
||||
---
|
||||
|
||||
### `host/showStatus`
|
||||
|
||||
Shows a status message in the CmdPal status bar. The extension mints a stable
|
||||
`statusId` and includes it so the same status can later be updated or hidden by id.
|
||||
Re-sending `host/showStatus` with an existing `statusId` updates that status in place
|
||||
(this is what `ExtensionHost.updateStatus` does). The `message` object is retained for
|
||||
compatibility with a host that still matches on message text.
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "host/showStatus",
|
||||
"params": {
|
||||
"statusId": "status-1",
|
||||
"message": {
|
||||
"Message": "Loading data...",
|
||||
"State": 0
|
||||
},
|
||||
"progress": { "isIndeterminate": true },
|
||||
"context": "extension"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `host/hideStatus`
|
||||
|
||||
Hides a previously shown status message, identified by the `statusId` returned when it
|
||||
was shown. The `message` object is included for compatibility with a host that still
|
||||
matches on message text.
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "host/hideStatus",
|
||||
"params": {
|
||||
"statusId": "status-1",
|
||||
"message": {
|
||||
"Message": "Loading data...",
|
||||
"State": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `host/copyText`
|
||||
|
||||
Copies text to the system clipboard (since Node.js doesn't have clipboard access).
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "host/copyText",
|
||||
"params": {
|
||||
"text": "Text to copy to clipboard"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Protocol Summary Table
|
||||
|
||||
| Method | Direction | Type | Purpose |
|
||||
|--------|-----------|------|---------|
|
||||
| `initialize` | Host → Ext | Request | Initialize extension |
|
||||
| `provider/getTopLevelCommands` | Host → Ext | Request | Get top-level commands |
|
||||
| `provider/getFallbackCommands` | Host → Ext | Request | Get fallback commands |
|
||||
| `provider/getCommand` | Host → Ext | Request | Get command by ID |
|
||||
| `provider/getSettings` | Host → Ext | Request | Get settings page |
|
||||
| `command/invoke` | Host → Ext | Request | Invoke a command |
|
||||
| `listPage/getItems` | Host → Ext | Request | Get list page items |
|
||||
| `listPage/setSearchText` | Host → Ext | Request | Update search query |
|
||||
| `listPage/setFilter` | Host → Ext | Request | Update active filter |
|
||||
| `listPage/loadMore` | Host → Ext | Request | Load more items |
|
||||
| `fallback/updateQuery` | Host → Ext | Notification or request | Update fallback query |
|
||||
| `contentPage/getContent` | Host → Ext | Request | Get content page content |
|
||||
| `form/submit` | Host → Ext | Request | Submit form data |
|
||||
| `dispose` | Host → Ext | Notification | Clean up before exit |
|
||||
| `provider/itemsChanged` | Ext → Host | Notification | Provider items have changed |
|
||||
| `listPage/itemsChanged` | Ext → Host | Notification | Items have changed |
|
||||
| `command/propChanged` | Ext → Host | Notification | Command props changed |
|
||||
| `host/logMessage` | Ext → Host | Notification | Log message |
|
||||
| `host/showStatus` | Ext → Host | Notification | Show status bar message |
|
||||
| `host/hideStatus` | Ext → Host | Notification | Hide status bar message |
|
||||
| `host/copyText` | Ext → Host | Notification | Copy text to clipboard |
|
||||
502
src/modules/cmdpal/doc/json-rpc-spec/04-manifest-packaging.md
Normal file
502
src/modules/cmdpal/doc/json-rpc-spec/04-manifest-packaging.md
Normal file
@@ -0,0 +1,502 @@
|
||||
# 04 - Manifest, Packaging, and Installation
|
||||
|
||||
## Extension Project Structure
|
||||
|
||||
A CmdPal JavaScript extension is a standard Node.js project. Extension metadata is declared in the `cmdpal` field of `package.json` (similar to how VS Code uses `contributes`):
|
||||
|
||||
```
|
||||
my-extension/
|
||||
├── package.json # Node.js manifest + "cmdpal" section (required)
|
||||
├── dist/
|
||||
│ └── index.js # Compiled entry point
|
||||
├── src/
|
||||
│ └── index.ts # TypeScript source
|
||||
├── tsconfig.json # TypeScript config
|
||||
├── node_modules/ # Dependencies (ideally bundled)
|
||||
└── icon.png # Extension icon (optional)
|
||||
```
|
||||
|
||||
The key files:
|
||||
- **`package.json`**: Standard Node.js package manifest with an added `cmdpal` section for CmdPal-specific metadata
|
||||
- **`dist/index.js`**: The compiled JavaScript entry point that CmdPal will execute
|
||||
|
||||
---
|
||||
|
||||
## `package.json` Schema
|
||||
|
||||
CmdPal discovers extensions by finding directories with a `package.json` that contains a `cmdpal` object. Top-level npm fields provide identity; the `cmdpal` section provides CmdPal-specific metadata. The parsed `cmdpal` fields are `displayName`, `icon`, `publisher`, `main`, `debug`, and `debugPort`.
|
||||
|
||||
### Full Example
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@microsoft/cmdpal-my-extension",
|
||||
"version": "1.0.0",
|
||||
"description": "A brief description of the extension",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"cmdpal": {
|
||||
"displayName": "My Extension",
|
||||
"icon": "icon.png",
|
||||
"publisher": "your-name",
|
||||
"debug": false,
|
||||
"debugPort": 9230
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/cmdpal-sdk": "file:../../ts-sdk"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.0"
|
||||
},
|
||||
"keywords": ["cmdpal", "powertoys", "command-palette"]
|
||||
}
|
||||
```
|
||||
|
||||
### Field Reference
|
||||
|
||||
#### Top-level fields (standard npm)
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | ✅ | Package identifier. Must be unique across installed extensions. Used as the extension ID. |
|
||||
| `version` | `string` | ❌ | Semantic version string (e.g., `"1.0.0"`). |
|
||||
| `description` | `string` | ❌ | Brief description shown in the extension gallery and settings. |
|
||||
| `author` | `string` or `object` | ❌ | npm author. Used as the publisher name only when `cmdpal.publisher` is absent. Accepts the string form `"Name <email> (url)"` or an object with a `name` property; only the name is used. |
|
||||
| `main` | `string` | Conditional | Relative path to the entry point JavaScript file. Required when `cmdpal.main` is not specified. This is what `node` executes. |
|
||||
| `engines.node` | `string` | ❌ | Node.js version requirement (expected value: `">=22.0.0"`). |
|
||||
|
||||
#### `cmdpal` section fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `displayName` | `string` | ❌ | Human-readable name shown in CmdPal UI. Falls back to `name` if not provided. |
|
||||
| `icon` | `string` | ❌ | Icon glyph character (e.g., `"\uE943"`) or a relative path to an icon file (PNG recommended) inside the package. A relative path is resolved against the package's own directory and must stay inside it; see [Icon resolution](#icon-resolution). |
|
||||
| `publisher` | `string` | ❌ | Author or publisher name. When omitted, the top-level npm `author` name is used as a fallback. |
|
||||
| `debug` | `boolean` | ❌ | When `true`, starts Node.js with `--inspect` for debugger attachment. Default: `false`. |
|
||||
| `debugPort` | `integer` | ❌ | Inspector port when `debug` is `true`. If not specified, auto-assigned starting at 9229. |
|
||||
| `main` | `string` | ❌ | Optional override of the top-level `main` field (for packages where the CmdPal entry point differs from the npm main). |
|
||||
|
||||
### Validation Rules
|
||||
|
||||
CmdPal parses each `package.json` and only loads the directory as an extension when every rule below passes. A failure is fatal for that extension: the directory is skipped (during discovery) or the install is rejected (from the gallery), and the reason is logged. The rules are enforced in `JSExtensionManifest.TryParse`.
|
||||
|
||||
1. **`cmdpal` object present.** The manifest must contain a `cmdpal` object (even if empty: `"cmdpal": {}`).
|
||||
2. **`name` present.** The top-level `name` must be present and non-empty.
|
||||
3. **Entry point declared.** `cmdpal.main` (preferred) or the top-level `main` must specify an entry point. If both are missing or blank, the extension is rejected.
|
||||
4. **Entry point stays inside the extension.** The entry point must be a **relative** path (a rooted or absolute path is rejected) that resolves to a location **inside** the extension directory. A path that escapes the directory with `..` is rejected so an extension cannot point its entry point at a file outside its own folder.
|
||||
5. **Entry point is a JavaScript module.** The resolved entry point must end in `.js`, `.mjs`, or `.cjs`. Any other extension (for example an uncompiled `.ts` source) is rejected, because the host runs the file directly with `node`.
|
||||
6. **Entry point exists.** The resolved entry point must be an existing file on disk. A `main` that points at a file that was never built or shipped is rejected.
|
||||
7. **No symlink or junction escape.** After confirming the file exists, the resolved entry point is re-checked against the real filesystem: a symbolic link, junction, or other reparse point that redirects the entry point outside the extension directory is rejected, even when the lexical path (rule 4) stayed inside it.
|
||||
|
||||
A resolved relative icon (`cmdpal.icon`) is subject to the same containment rules; see [Icon resolution](#icon-resolution).
|
||||
|
||||
### Icon resolution
|
||||
|
||||
The `cmdpal.icon` value is interpreted as follows:
|
||||
|
||||
- A **glyph** (for example, `"\uE943"`) or an **absolute URI** (for example, an
|
||||
`https://` or `ms-appx://` value) is used exactly as written.
|
||||
- A **relative file path** (for example, `"icon.png"` or `"assets/icon.png"`) is
|
||||
resolved against the extension's own installed directory, which is the folder that
|
||||
contains its `package.json`.
|
||||
|
||||
A resolved relative icon must stay **inside** the package directory. The path is
|
||||
rejected (and the extension shows no icon rather than loading an out-of-package file)
|
||||
when:
|
||||
|
||||
- it escapes the package with `..`,
|
||||
- it is redirected outside the package by a symbolic link, junction, or other reparse
|
||||
point, or
|
||||
- the target file does not exist.
|
||||
|
||||
Keep icon files inside your package (and list them in `files`) so they are present in
|
||||
the installed directory. There is a single `icon` value; separate light and dark
|
||||
variants are not currently expressed in the manifest.
|
||||
|
||||
---
|
||||
|
||||
## Installation Directory
|
||||
|
||||
Extensions are installed to:
|
||||
|
||||
```
|
||||
%LOCALAPPDATA%\Microsoft\PowerToys\CmdPal\JSExtensions\
|
||||
```
|
||||
|
||||
Each extension occupies its own subdirectory:
|
||||
|
||||
```
|
||||
JSExtensions/
|
||||
├── my-extension/
|
||||
│ ├── package.json ← contains "cmdpal" section
|
||||
│ ├── dist/
|
||||
│ │ └── index.js
|
||||
│ └── node_modules/
|
||||
├── another-extension/
|
||||
│ ├── package.json
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
### Discovery
|
||||
|
||||
The `JsonRpcExtensionService` watches this directory with a `FileSystemWatcher`:
|
||||
- **New directory with valid `package.json`** (for example, a sideloaded extension copied in) is loaded automatically
|
||||
- **Directory removed** unloads the extension and terminates its Node.js process
|
||||
- **`*.js` file changed** within an extension triggers hot-reload (500ms debounce)
|
||||
|
||||
This means for sideloaded development:
|
||||
- Installing an extension = copying a fully prepared directory into `JSExtensions/`
|
||||
- Uninstalling = deleting the directory
|
||||
- Updating = replacing files (hot-reload handles `*.js` changes)
|
||||
|
||||
Gallery installs do not rely on the watcher observing a half-written directory. The
|
||||
installer prepares the extension in a staging location outside `JSExtensions/`,
|
||||
verifies it, and then moves the finished directory into place in a single atomic
|
||||
step. See [Installation Flow](#installation-flow) for the full sequence. Because the
|
||||
directory only ever appears complete, the watcher never sees a partially copied
|
||||
extension.
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Creating a New Extension
|
||||
|
||||
1. **Create the project directory:**
|
||||
```bash
|
||||
mkdir my-extension && cd my-extension
|
||||
npm init -y
|
||||
```
|
||||
|
||||
2. **Install the SDK:**
|
||||
```bash
|
||||
npm install ..\..\ts-sdk
|
||||
```
|
||||
|
||||
3. **Add the `cmdpal` section to `package.json`:**
|
||||
```json
|
||||
{
|
||||
"name": "my-extension",
|
||||
"version": "1.0.0",
|
||||
"description": "My awesome CmdPal extension",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"cmdpal": {
|
||||
"displayName": "My Extension",
|
||||
"debug": true
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/cmdpal-sdk": "file:../../ts-sdk"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Create `tsconfig.json`:**
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
```
|
||||
|
||||
5. **Write your extension** in `src/index.ts` (see [05-getting-started.md](./05-getting-started.md))
|
||||
|
||||
6. **Build:**
|
||||
```bash
|
||||
npx tsc
|
||||
```
|
||||
|
||||
### Development Installation
|
||||
|
||||
For development, symlink or copy your extension to the JSExtensions directory:
|
||||
|
||||
```powershell
|
||||
# Option 1: Copy
|
||||
Copy-Item -Recurse ./my-extension "$env:LOCALAPPDATA\Microsoft\PowerToys\CmdPal\JSExtensions\my-extension"
|
||||
|
||||
# Option 2: Junction link (recommended for development)
|
||||
New-Item -ItemType Junction -Path "$env:LOCALAPPDATA\Microsoft\PowerToys\CmdPal\JSExtensions\my-extension" -Target (Resolve-Path ./my-extension)
|
||||
```
|
||||
|
||||
With a junction link, changes to your source files are reflected immediately (after build). The `*.js` file watcher triggers hot-reload automatically.
|
||||
|
||||
### Debugging
|
||||
|
||||
1. Set `"debug": true` in the `cmdpal` section of `package.json`
|
||||
2. Optionally set `"debugPort": 9230` (or any available port)
|
||||
3. Open Chrome DevTools: `chrome://inspect` or attach VS Code's debugger
|
||||
4. The Node.js process starts with `--inspect=<port>`, ready for debugger attachment
|
||||
|
||||
---
|
||||
|
||||
## Production Packaging
|
||||
|
||||
### SDK distribution status
|
||||
|
||||
Registry distribution of `@microsoft/cmdpal-sdk` is **not yet supported**: the SDK
|
||||
is not published to a public npm registry, so a published package cannot depend on
|
||||
it by version. The way you reference the SDK differs between local development and
|
||||
gallery submission, and getting this wrong is the most common reason a package fails
|
||||
to install from the gallery.
|
||||
|
||||
**Local development.** Reference the in-repo SDK directly with
|
||||
`"@microsoft/cmdpal-sdk": "file:../../ts-sdk"` (or `npm link`). This is convenient
|
||||
while iterating inside this repository, but it is **not** submittable to the gallery:
|
||||
a `file:` dependency resolves to a local path, not a registry URL, so it carries no
|
||||
integrity (SRI) hash and cannot appear in an `npm-shrinkwrap.json` as a verifiable,
|
||||
trusted entry. The gallery installer rejects any package whose dependency closure
|
||||
contains such an untrusted entry.
|
||||
|
||||
**Gallery submission.** The published package must not carry a `file:` dependency on
|
||||
the SDK. Two paths satisfy the gallery's trusted-lockfile rules:
|
||||
|
||||
1. **Bundle the SDK into `dist/`** (required today, recommended). Use a bundler (for
|
||||
example, `esbuild` or `rollup`) so the SDK is inlined into the files you ship under
|
||||
`dist/`. The published package then has **no runtime dependency** on
|
||||
`@microsoft/cmdpal-sdk` at all, so nothing about the SDK needs to appear in the
|
||||
lockfile, and `npm install <your-package>` needs no access to this repository. This
|
||||
is the only submittable path until the SDK is published to a registry.
|
||||
2. **Depend on the published SDK by version** (available once the SDK ships to a
|
||||
trusted registry). Declare `"@microsoft/cmdpal-sdk": "<exact-version>"` under
|
||||
`dependencies` so it resolves to a registry URL with an integrity hash and is frozen
|
||||
in the embedded `npm-shrinkwrap.json` (see [Freezing the dependency
|
||||
closure](#freezing-the-dependency-closure-npm-shrinkwrapjson)). Do **not** use a
|
||||
vendored `file:` tarball (`file:./microsoft-cmdpal-sdk-<version>.tgz`) for
|
||||
submission: like `file:../../ts-sdk`, it lacks a trusted registry URL and SRI and is
|
||||
rejected.
|
||||
|
||||
Do not ship a gallery package whose path to the SDK is any `file:` reference
|
||||
(`file:../../ts-sdk` or a vendored `.tgz`). Those forms are for **local development
|
||||
only** (see [Development Setup](#development-setup)); bundle the SDK instead.
|
||||
|
||||
### npm Package Structure
|
||||
|
||||
Extensions are distributed as standard npm packages. The recommended `package.json`
|
||||
for a bundled production build:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@publisher/cmdpal-my-extension",
|
||||
"version": "1.0.0",
|
||||
"description": "My CmdPal extension",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"cmdpal": {
|
||||
"displayName": "My Extension",
|
||||
"icon": "icon.png",
|
||||
"publisher": "your-name"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && esbuild dist/index.js --bundle --platform=node --format=esm --outfile=dist/index.js --allow-overwrite",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"icon.png"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@microsoft/cmdpal-sdk": "file:../../ts-sdk",
|
||||
"esbuild": "^0.23.0",
|
||||
"typescript": "^5.8.0"
|
||||
},
|
||||
"keywords": ["cmdpal", "powertoys", "command-palette"],
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The SDK appears only under `devDependencies` because the build step inlines it into
|
||||
`dist/`. The shipped package therefore lists no runtime dependency on
|
||||
`@microsoft/cmdpal-sdk`.
|
||||
|
||||
The build is wired to the **`prepack`** lifecycle hook, not `prepublishOnly`, on
|
||||
purpose. `npm pack` (which produces the tarball the gallery ultimately installs, and
|
||||
the tarball you validate below) runs `prepack` but does **not** run `prepublishOnly`;
|
||||
only `npm publish` runs `prepublishOnly`. Using `prepack` guarantees `dist/` is
|
||||
rebuilt whenever the tarball is assembled, whether you are validating locally or
|
||||
publishing, so a clean checkout can never pack a stale or missing `dist/`. The SDK
|
||||
itself takes the equivalent belt-and-suspenders approach: its `verify:pack` script
|
||||
runs `npm run build` explicitly before `npm pack` rather than trusting a publish-only
|
||||
hook.
|
||||
|
||||
### Validating a clean install
|
||||
|
||||
To confirm your package installs without the PowerToys repository present, pack it
|
||||
and install it into a throwaway directory:
|
||||
|
||||
```powershell
|
||||
npm pack # runs the prepack build, then produces publisher-cmdpal-my-extension-1.0.0.tgz with dist/ inside
|
||||
$temp = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ("cmdpal-smoke-" + [guid]::NewGuid()))
|
||||
Copy-Item .\publisher-cmdpal-my-extension-1.0.0.tgz $temp
|
||||
Push-Location $temp
|
||||
npm init -y | Out-Null
|
||||
npm install .\publisher-cmdpal-my-extension-1.0.0.tgz
|
||||
node -e "import('@publisher/cmdpal-my-extension').then(() => console.log('loaded'))"
|
||||
Pop-Location
|
||||
```
|
||||
|
||||
A bundled package resolves with no reference back to `ts-sdk`. The `ts-sdk` package
|
||||
ships its own equivalent check (`npm run verify:pack`) that packs the SDK, installs
|
||||
the tarball into a temporary project, and type-checks against it.
|
||||
|
||||
### Naming Convention
|
||||
|
||||
Recommended npm package naming: `@publisher/cmdpal-<name>` or `cmdpal-<name>`.
|
||||
|
||||
The `cmdpal-` prefix helps with discoverability and could be used for future npm-based discovery.
|
||||
|
||||
---
|
||||
|
||||
## Extension Gallery Integration
|
||||
|
||||
|
||||
### Gallery Manifest Entry
|
||||
|
||||
The existing CmdPal extension gallery pulls from a feed that lists available extensions. The feed is a wrapped document with an `extensions` array; each entry describes one extension and how to install it. For a JavaScript/TypeScript extension, the install information lives in an `installSources` entry whose `type` is `"jsonrpc"`:
|
||||
|
||||
```json
|
||||
{
|
||||
"extensions": [
|
||||
{
|
||||
"id": "publisher.cmdpal-my-extension",
|
||||
"title": "My Extension",
|
||||
"description": "Does amazing things from the Command Palette.",
|
||||
"shortDescription": "Does amazing things.",
|
||||
"author": {
|
||||
"name": "Your Name",
|
||||
"url": "https://example.com"
|
||||
},
|
||||
"homepage": "https://example.com/my-extension",
|
||||
"tags": ["cmdpal", "productivity"],
|
||||
"installSources": [
|
||||
{
|
||||
"type": "jsonrpc",
|
||||
"npm": {
|
||||
"package": "@publisher/cmdpal-my-extension",
|
||||
"version": "1.0.0",
|
||||
"integrity": "sha512-3sxT2b3Ea2u2vLXA7Yl0dOZH3Rm9j1p3T0i8b9m2wJ0kZ8t2K1cQ0f8p7L6r5S4d3F2a1B0c9D8e7F6g5H4i3J2k1L0m9N8o7P6q5R4s3T2u1V0w==",
|
||||
"registry": "https://registry.npmjs.org"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Fields on the `jsonrpc` install source's `npm` object:
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `package` | Yes | npm package identifier to install. |
|
||||
| `version` | Yes | Exact version to install. Ranges and dist-tags (such as `latest`) are rejected so the installed bytes always match the approved artifact. |
|
||||
| `integrity` | Yes | Subresource Integrity (`sha512-...`) of the approved tarball. The installer verifies the resolved package against this value before promoting it. |
|
||||
| `registry` | No | Absolute HTTPS registry URL. When present it must be on the approved allowlist. When omitted, the machine's default registry is used. |
|
||||
|
||||
An install source that does not pin both `version` and `integrity` is **not installable**: the installer fails closed rather than fetching an unverified package. Optional presentation fields (`shortDescription`, `homepage`, `iconUrl`, `screenshotUrls`, `readme`, `tags`) and the COM `detection` block are documented with the gallery models and are not required for a `jsonrpc` extension.
|
||||
|
||||
The `type: "jsonrpc"` install source distinguishes JavaScript extensions from COM-based extensions.
|
||||
|
||||
### Freezing the dependency closure (`npm-shrinkwrap.json`)
|
||||
|
||||
Gallery submission requires the published package to embed an `npm-shrinkwrap.json`.
|
||||
The gallery installer rejects a package that does not ship one, because without it the
|
||||
transitive dependency closure is not frozen: a later publish of one of your
|
||||
dependencies could change the bytes that land on a user's machine, and there would be
|
||||
no lockfile pinning each dependency to an exact version, resolved registry URL, and
|
||||
integrity (SRI) hash. `npm-shrinkwrap.json` is npm's publishable lockfile (unlike
|
||||
`package-lock.json`, npm includes it in the tarball), so it travels with the package
|
||||
and lets the installer verify the whole closure against trusted registry entries.
|
||||
|
||||
If you **bundle** the SDK into `dist/` (the path required today) your extension may
|
||||
have no runtime dependencies at all, but you must still ship an `npm-shrinkwrap.json`
|
||||
so the closure is explicitly frozen (an empty or dependency-free closure is still a
|
||||
verified one).
|
||||
|
||||
To create and maintain it:
|
||||
|
||||
```powershell
|
||||
npm install # resolve the exact dependency tree into package-lock.json
|
||||
npm shrinkwrap # rename/convert it to a publishable npm-shrinkwrap.json
|
||||
```
|
||||
|
||||
Then commit `npm-shrinkwrap.json` and publish as usual (`npm publish`, or `npm pack`
|
||||
for the tarball the gallery approves). npm includes `npm-shrinkwrap.json` in the
|
||||
tarball automatically, so no `files` entry is needed for it. Regenerate it whenever
|
||||
your dependencies change, which in practice means **once per release**: bump the
|
||||
version, run `npm install` and `npm shrinkwrap` again, and commit the refreshed
|
||||
lockfile alongside the new version.
|
||||
|
||||
### Installation Flow
|
||||
|
||||
When a user clicks "Install" for a JavaScript extension in the gallery, the installer prepares the extension out of sight of the watcher and only reveals it once it is verified and complete:
|
||||
|
||||
1. The `version` and `integrity` fields are validated, and any `registry` is checked against the HTTPS allowlist. A source that omits `version` or `integrity` is rejected before anything is downloaded.
|
||||
2. `npm pack <package>@<version>` downloads the exact tarball (with lifecycle scripts disabled) into a fresh GUID-named staging directory that lives **outside** the watched `JSExtensions/` root, on the same volume so the later move is atomic. The package is never installed as a dependency, so npm cannot re-resolve a range or nest it under a parent `node_modules`.
|
||||
3. The integrity that npm reports for the packed tarball is compared against the `integrity` value from the feed. A mismatch aborts the install.
|
||||
4. The tarball is extracted so the published package becomes the staged root (npm roots every tarball entry under `package/`). The installer then requires a publisher-provided `npm-shrinkwrap.json` in that root and rejects the package when it is missing.
|
||||
5. `npm ci` runs inside the extracted root, installing the frozen dependency closure into the package's own `node_modules` without re-resolving any version. The resolved lockfile is verified so the whole closure matches trusted registry entries.
|
||||
6. The installer parses the root `package.json` and confirms the manifest identity (package name) and `version` match what the feed approved before anything is promoted.
|
||||
7. The finished directory is promoted into `JSExtensions\<id>` with a single atomic `Directory.Move`, so the directory only ever appears complete.
|
||||
8. The host is asked to refresh and **awaits provider registration** (`OnProviderAdded`) before the install is reported as successful. The staging directory is cleaned up regardless of outcome.
|
||||
|
||||
Because promotion is atomic and registration is awaited, a completed gallery install is guaranteed to be loadable when the install call returns; the `FileSystemWatcher` is not relied upon to catch a partially written directory.
|
||||
|
||||
### Uninstallation Flow
|
||||
|
||||
When a user clicks "Uninstall":
|
||||
|
||||
1. CmdPal terminates the extension's Node.js process
|
||||
2. The extension directory is deleted from `JSExtensions/`
|
||||
3. `FileSystemWatcher` detects the removal → extension is unloaded
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Process Isolation
|
||||
|
||||
Each JavaScript extension runs in its own Node.js process:
|
||||
- Separate memory space
|
||||
- Separate event loop
|
||||
- No direct access to other extensions or CmdPal internals
|
||||
- Communication only through the JSON-RPC protocol
|
||||
|
||||
This process separation is a fault and crash boundary, not a security boundary. An extension is not sandboxed: it runs with the user's full privileges (see [Permissions](#permissions) below), so a separate process does not contain what the extension's own code is allowed to do.
|
||||
|
||||
### Permissions
|
||||
|
||||
Currently, JavaScript extensions have the same permissions as the Node.js process:
|
||||
- File system access
|
||||
- Network access
|
||||
- Process spawning
|
||||
|
||||
Future considerations:
|
||||
- Extension permission declarations in `package.json` `cmdpal` section
|
||||
- User consent prompts for sensitive permissions
|
||||
- Sandboxing via Node.js `--experimental-policy` or similar mechanisms
|
||||
|
||||
### Trust Model
|
||||
|
||||
- Extensions installed from the gallery are implicitly trusted by the user
|
||||
- Sideloaded extensions (copied to JSExtensions/) have no verification
|
||||
415
src/modules/cmdpal/doc/json-rpc-spec/05-getting-started.md
Normal file
415
src/modules/cmdpal/doc/json-rpc-spec/05-getting-started.md
Normal file
@@ -0,0 +1,415 @@
|
||||
# 05 - Getting Started: Build Your First CmdPal Extension
|
||||
|
||||
This guide walks you through building a CmdPal JavaScript extension from scratch. By the end you will have a working extension with a searchable list page, a content page, and a settings page. It uses the same SDK idioms as the shipped parity sample under `src/modules/cmdpal/ext/SampleJSExtension`, so you can read that project alongside this guide.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22 or newer, installed and on your PATH.
|
||||
- PowerToys with Command Palette enabled.
|
||||
- A text editor. VS Code is recommended.
|
||||
- A local copy of the `@microsoft/cmdpal-sdk` package. The SDK is not published to npm yet, so you reference it from the repo at `src/modules/cmdpal/ts-sdk` through a relative `file:` dependency. Build the SDK once (`npm ci && npm run build` inside `ts-sdk`) so that its `dist` folder exists.
|
||||
|
||||
## Step 1: Scaffold the project
|
||||
|
||||
```bash
|
||||
mkdir my-first-extension && cd my-first-extension
|
||||
npm init -y
|
||||
npm install --save-dev typescript @types/node
|
||||
```
|
||||
|
||||
Add the SDK as a relative dependency. Adjust the path so it points at your checkout of `src/modules/cmdpal/ts-sdk`:
|
||||
|
||||
```bash
|
||||
npm install "@microsoft/cmdpal-sdk@file:../path/to/src/modules/cmdpal/ts-sdk"
|
||||
```
|
||||
|
||||
Create `tsconfig.json`. The SDK is an ES module, so the project uses `NodeNext` module resolution and emits ES modules:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"],
|
||||
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
|
||||
Update `package.json` so it is an ES module, points `main` at the built entry, and carries a `cmdpal` section:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-first-extension",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"cmdpal": {
|
||||
"displayName": "My First Extension",
|
||||
"icon": "\uE8A5",
|
||||
"publisher": "Your Name",
|
||||
"debug": true
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/cmdpal-sdk": "file:../path/to/src/modules/cmdpal/ts-sdk"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.0",
|
||||
"typescript": "^5.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Because the project uses `NodeNext` resolution, every relative import in your own source must include the `.js` extension (for example `import { MainPage } from './mainPage.js'`). Imports from the `@microsoft/cmdpal-sdk` package use a bare specifier and do not need an extension.
|
||||
|
||||
## Step 2: Create a simple command and a list page
|
||||
|
||||
Create `src/index.ts`. Commands extend `InvokableCommandBase` and return a `CommandResult`, which is a plain object with a `kind` string and optional `args`. Pages extend `ListPageBase`. The provider extends `CommandProviderBase` and is started with `run`, which takes a factory function:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
CommandItemBase,
|
||||
CommandProviderBase,
|
||||
InvokableCommandBase,
|
||||
ListItemBase,
|
||||
ListPageBase,
|
||||
iconFromGlyph,
|
||||
run,
|
||||
} from '@microsoft/cmdpal-sdk';
|
||||
import type {
|
||||
CommandResult,
|
||||
ICommandItem,
|
||||
IListItem,
|
||||
} from '@microsoft/cmdpal-sdk';
|
||||
|
||||
// A command that shows a toast. `invoke` returns a CommandResult object.
|
||||
class GreetCommand extends InvokableCommandBase {
|
||||
readonly id = 'greet';
|
||||
readonly name = 'Say Hello';
|
||||
|
||||
override invoke(): CommandResult {
|
||||
return { kind: 'showToast', args: { message: 'Hello from my extension!' } };
|
||||
}
|
||||
}
|
||||
|
||||
// The main list page. `getItems` may be synchronous or return a Promise.
|
||||
class MainPage extends ListPageBase {
|
||||
readonly id = 'main-page';
|
||||
readonly name = 'My First Extension';
|
||||
readonly title = 'My First Extension';
|
||||
|
||||
override icon = iconFromGlyph('\uE8A5');
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
return [
|
||||
new ListItemBase({
|
||||
command: new GreetCommand(),
|
||||
title: 'Say Hello',
|
||||
subtitle: 'Shows a greeting toast',
|
||||
icon: iconFromGlyph('\uE76E'),
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// The provider exposes one top-level command that opens the main page.
|
||||
class MyProvider extends CommandProviderBase {
|
||||
readonly id = 'my-first-extension';
|
||||
readonly displayName = 'My First Extension';
|
||||
|
||||
override icon = iconFromGlyph('\uE8A5');
|
||||
|
||||
private readonly mainPage = new MainPage();
|
||||
|
||||
override topLevelCommands(): ICommandItem[] {
|
||||
return [
|
||||
new CommandItemBase({
|
||||
command: this.mainPage,
|
||||
title: 'My First Extension',
|
||||
subtitle: 'A tutorial extension',
|
||||
icon: iconFromGlyph('\uE8A5'),
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
run(() => new MyProvider());
|
||||
```
|
||||
|
||||
A few things worth noting:
|
||||
|
||||
- `id`, `name`, `title`, and `displayName` are declared as plain class fields that satisfy the abstract members on the base classes. You do not need `override` when you are implementing an abstract member, but you do need `override` on members that already have an implementation on the base class, such as `icon`, `invoke`, and `getItems`.
|
||||
- `CommandResult.kind` is one of `dismiss`, `goHome`, `goBack`, `hide`, `keepOpen`, `goToPage`, `showToast`, or `confirm`. There is no enum to import. You write the string directly.
|
||||
- `run` accepts a factory (`() => new MyProvider()`) rather than an instance, so the host can construct the provider at the right point in startup.
|
||||
|
||||
## Step 3: Build and install for local testing
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Then link the built extension into the CmdPal extensions directory. A directory junction lets CmdPal load the folder while you keep editing in place:
|
||||
|
||||
```powershell
|
||||
$extensionsDir = "$env:LOCALAPPDATA\Microsoft\PowerToys\CmdPal\JSExtensions"
|
||||
New-Item -ItemType Directory -Force -Path $extensionsDir | Out-Null
|
||||
New-Item -ItemType Junction -Path "$extensionsDir\my-first-extension" -Target (Resolve-Path .)
|
||||
```
|
||||
|
||||
Open CmdPal. You should see "My First Extension" in the list. Select it to open your page, then run "Say Hello" to see the toast. For discovery to succeed the folder must contain a `package.json` with a `cmdpal` section, a non-empty `name`, and a `main` (or `cmdpal.main`) that resolves to a file that exists, which is why you build before installing.
|
||||
|
||||
## Step 4: Add a searchable (dynamic) list page
|
||||
|
||||
A dynamic list page filters its items as the user types. Extend `DynamicListPageBase`, read `this.searchText`, and rebuild the items in `getItems`:
|
||||
|
||||
```typescript
|
||||
import { DynamicListPageBase, ListItemBase } from '@microsoft/cmdpal-sdk';
|
||||
import type { IListItem } from '@microsoft/cmdpal-sdk';
|
||||
|
||||
const fruits = [
|
||||
{ title: 'Apple', emoji: '\u{1F34E}' },
|
||||
{ title: 'Banana', emoji: '\u{1F34C}' },
|
||||
{ title: 'Cherry', emoji: '\u{1F352}' },
|
||||
{ title: 'Dragon Fruit', emoji: '\u{1F409}' },
|
||||
{ title: 'Elderberry', emoji: '\u{1FAD0}' },
|
||||
];
|
||||
|
||||
class SearchablePage extends DynamicListPageBase {
|
||||
readonly id = 'searchable-page';
|
||||
readonly name = 'Fruit Search';
|
||||
readonly title = 'Fruit Search';
|
||||
|
||||
override placeholderText = 'Search fruits...';
|
||||
|
||||
override setSearchText(text: string): void {
|
||||
this.searchText = text;
|
||||
// Tell the host the items changed so it re-queries getItems.
|
||||
this.notifyItemsChanged();
|
||||
}
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
const query = (this.searchText ?? '').toLowerCase();
|
||||
return fruits
|
||||
.filter((item) => item.title.toLowerCase().includes(query))
|
||||
.map(
|
||||
(item) =>
|
||||
new ListItemBase({
|
||||
command: new GreetCommand(),
|
||||
title: `${item.emoji} ${item.title}`,
|
||||
subtitle: 'A delicious fruit',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add the searchable page to the main page by returning another `ListItemBase` whose `command` is `new SearchablePage()`.
|
||||
|
||||
## Step 5: Add a content page with markdown
|
||||
|
||||
Content pages render blocks of rich content. Extend `ContentPageBase` and return an array of `Content` objects from `getContent`. Each markdown block is a plain object with `type: 'markdown'` and a `body` string:
|
||||
|
||||
```typescript
|
||||
import { ContentPageBase } from '@microsoft/cmdpal-sdk';
|
||||
import type { Content } from '@microsoft/cmdpal-sdk';
|
||||
|
||||
class AboutPage extends ContentPageBase {
|
||||
readonly id = 'about-page';
|
||||
readonly name = 'About';
|
||||
readonly title = 'About';
|
||||
|
||||
override getContent(): Content[] {
|
||||
return [
|
||||
{
|
||||
type: 'markdown',
|
||||
body: [
|
||||
'# My First Extension',
|
||||
'',
|
||||
'This extension was built with the CmdPal TypeScript SDK.',
|
||||
'',
|
||||
'## Features',
|
||||
'- Simple commands with toast notifications',
|
||||
'- Searchable lists with dynamic filtering',
|
||||
'- Rich content with markdown',
|
||||
].join('\n'),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `Content` union also covers forms (`type: 'form'`), images (`type: 'image'`), plain text (`type: 'plainText'`), and a tree (`type: 'tree'`). See the TypeScript SDK reference for the full shape of each.
|
||||
|
||||
## Step 6: Add a settings page
|
||||
|
||||
Settings are built with the `Settings` helper plus the individual setting types. A settings page is a content page that renders the settings as a form:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ChoiceSetSetting,
|
||||
ContentPageBase,
|
||||
ExtensionHost,
|
||||
Settings,
|
||||
TextSetting,
|
||||
ToggleSetting,
|
||||
} from '@microsoft/cmdpal-sdk';
|
||||
import type { Content, SettingChoice } from '@microsoft/cmdpal-sdk';
|
||||
|
||||
const choices: SettingChoice[] = [
|
||||
{ title: 'Small', value: 'small' },
|
||||
{ title: 'Medium', value: 'medium' },
|
||||
{ title: 'Large', value: 'large' },
|
||||
];
|
||||
|
||||
class SettingsPage extends ContentPageBase {
|
||||
readonly id = 'settings';
|
||||
readonly name = 'Settings';
|
||||
readonly title = 'Settings';
|
||||
|
||||
private readonly settings = new Settings();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.settings.add(
|
||||
new ToggleSetting('showEmoji', 'Show emoji in results', true, 'Adds an emoji to each item'),
|
||||
);
|
||||
this.settings.add(
|
||||
new TextSetting('greeting', 'Custom greeting', 'Hello', 'Used by the Say Hello command'),
|
||||
);
|
||||
this.settings.add(
|
||||
new ChoiceSetSetting('size', 'Result size', choices, 'medium', 'Preferred item size'),
|
||||
);
|
||||
}
|
||||
|
||||
override getContent(): Content[] | Promise<Content[]> {
|
||||
const greeting = this.settings.getSetting<TextSetting>('greeting')?.value;
|
||||
ExtensionHost.log(`Current greeting is ${greeting}`);
|
||||
return this.settings.settingsPage.getContent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Expose the settings page like any other page, by returning a `ListItemBase` whose `command` is `new SettingsPage()`.
|
||||
|
||||
## Step 7: Add context commands, tags, and details
|
||||
|
||||
List items can carry a details pane, tags, and a context menu. The built-in commands `OpenUrlCommand` and `CopyTextCommand` cover common actions. `OpenUrlCommand` takes the URL first, then an optional name. `CopyTextCommand` takes the text, an optional name, and an optional toast message:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
CopyTextCommand,
|
||||
ListItemBase,
|
||||
OpenUrlCommand,
|
||||
} from '@microsoft/cmdpal-sdk';
|
||||
import type { ContextItem, IListItem, Tag } from '@microsoft/cmdpal-sdk';
|
||||
|
||||
function webTag(text: string): Tag {
|
||||
return { text, foreground: { hasValue: true, color: { r: 100, g: 200, b: 255, a: 255 } } };
|
||||
}
|
||||
|
||||
// Inside getItems():
|
||||
const copyUrl = new CopyTextCommand('https://github.com', 'Copy URL');
|
||||
|
||||
const moreCommands: ContextItem[] = [
|
||||
{ command: copyUrl, title: 'Copy URL', icon: iconFromGlyph('\uE8C8') },
|
||||
];
|
||||
|
||||
const item: IListItem = new ListItemBase({
|
||||
command: new OpenUrlCommand('https://github.com', 'Open GitHub'),
|
||||
title: 'GitHub',
|
||||
subtitle: 'Open GitHub in your browser',
|
||||
icon: iconFromGlyph('\uE774'),
|
||||
tags: [webTag('Web')],
|
||||
details: {
|
||||
title: 'GitHub',
|
||||
body: 'The world\'s leading software development platform.',
|
||||
metadata: [
|
||||
{ key: 'URL', data: { type: 'link', link: 'https://github.com', text: 'github.com' } },
|
||||
{ key: 'Topics', data: { type: 'tags', tags: [{ text: 'Development' }, { text: 'Git' }] } },
|
||||
],
|
||||
},
|
||||
moreCommands,
|
||||
});
|
||||
```
|
||||
|
||||
A note on context menus: nested context menus are supported. Each `ContextItem` has its own optional `moreCommands` array, so a context item can carry a sub-menu of further `ContextItem` entries, and the SDK serializes that nesting recursively. This mirrors the C# model, where a context item can itself expose more commands.
|
||||
|
||||
## Step 8: Rebuild and test
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
CmdPal watches each extension directory for `*.js` changes and hot-reloads within about 500 milliseconds, so after `tsc` finishes your extension reloads automatically. Changes under `node_modules` are ignored by the watcher.
|
||||
|
||||
## Debugging tips
|
||||
|
||||
### Enable debug mode
|
||||
|
||||
Set `"debug": true` in the `cmdpal` section of your `package.json`. The Node.js process then starts with `--inspect`, which lets you attach a debugger. You can pin the port with `"debugPort"`; otherwise ports are auto-assigned starting at 9229.
|
||||
|
||||
### Attach the VS Code debugger
|
||||
|
||||
Add an attach configuration to `.vscode/launch.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
"name": "Attach to CmdPal Extension",
|
||||
"port": 9229,
|
||||
"restart": true,
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
}
|
||||
```
|
||||
|
||||
### View logs and status
|
||||
|
||||
Use `ExtensionHost` to send messages to the CmdPal log and to show inline status:
|
||||
|
||||
```typescript
|
||||
import { ExtensionHost } from '@microsoft/cmdpal-sdk';
|
||||
|
||||
ExtensionHost.log('Fetching items...');
|
||||
// showStatus returns a stable id; keep it to hide the same status later.
|
||||
const statusId = ExtensionHost.showStatus('Working...', 'info', { isIndeterminate: true });
|
||||
ExtensionHost.hideStatus(statusId);
|
||||
```
|
||||
|
||||
### Common issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Extension not showing | Check that `package.json` has a `cmdpal` section, a non-empty `name`, and a `main` that resolves to a built file. Build before installing. |
|
||||
| Blank page | Check that `getItems` or `getContent` returns data. |
|
||||
| Command does nothing | Ensure `invoke` returns a valid `CommandResult`, for example `{ kind: 'showToast', args: { message: '...' } }`. |
|
||||
| Images not loading | Use `iconFromUrl` or `iconFromBase64`. Only absolute file paths are supported for `file://` sources. |
|
||||
| Import fails at runtime | Relative imports in your own code must end with `.js`, because the project uses `NodeNext` module resolution. |
|
||||
|
||||
## Next steps
|
||||
|
||||
- Read the [TypeScript SDK Reference](./02-typescript-sdk.md) for the full API.
|
||||
- Read the [JSON-RPC Protocol Specification](./03-jsonrpc-protocol.md) to understand the wire format.
|
||||
- Read the [Manifest and Packaging](./04-manifest-packaging.md) guide for the manifest fields and install layout.
|
||||
- Explore the [parity sample](../../ext/SampleJSExtension/) for a comprehensive, buildable example that mirrors the C# `SamplePagesExtension`.
|
||||
- Read the [Architecture Overview](./01-architecture.md) for how it all fits together.
|
||||
84
src/modules/cmdpal/doc/json-rpc-spec/overview.md
Normal file
84
src/modules/cmdpal/doc/json-rpc-spec/overview.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Command Palette JavaScript Extension System Design Specification
|
||||
|
||||
> **Status:** Draft, seeking community and internal feedback<br/>
|
||||
> **Last updated:** 2026-07-15
|
||||
|
||||
## Table of Contents
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [01. Architecture Overview](01-architecture.md) | Process model, extension lifecycle, transport, and security |
|
||||
| [02. TypeScript SDK Reference](02-typescript-sdk.md) | Full API surface: types, base classes, helpers, and runtime |
|
||||
| [03. JSON-RPC Protocol](03-jsonrpc-protocol.md) | Complete protocol specification: methods, notifications, framing |
|
||||
| [04. Extension Manifest and Packaging](04-manifest-packaging.md) | `package.json` schema, project structure, distribution |
|
||||
| [05. Getting Started](05-getting-started.md) | Build your first JS/TS extension step by step |
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Command Palette (CmdPal) is extending its extension model beyond in-process WinRT/COM extensions to support **JavaScript and TypeScript extensions** that run as isolated Node.js processes, communicating with the host over JSON-RPC 2.0 via stdio.
|
||||
|
||||
### Goals
|
||||
|
||||
1. **Developer accessibility.** Let web developers build CmdPal extensions using familiar tools (TypeScript, npm, Node.js).
|
||||
2. **Process isolation.** Extension crashes do not take down CmdPal, and extensions cannot corrupt host state.
|
||||
3. **Type-safe SDK.** Full TypeScript type definitions mirroring the C# toolkit surface.
|
||||
4. **Developer experience.** Hot-reload on file changes, debugger attachment, familiar project structure.
|
||||
5. **Feature parity.** JS extensions can create list pages, content pages, forms, grids, settings, and more.
|
||||
|
||||
### Non-Goals (v1)
|
||||
|
||||
- Browser/WebView-based extension UI rendering
|
||||
- Sandboxed filesystem access or permission model
|
||||
- Extension marketplace / auto-update infrastructure
|
||||
- Multi-language JSON-RPC support beyond JavaScript/TypeScript (Python, Go, and so on)
|
||||
|
||||
### Architecture at a Glance
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Host["Command Palette (WinUI Host)"]
|
||||
subgraph JS["JsonRpcExtensionService"]
|
||||
A["JSExtensionWrapper (ext A)"] --> ARPC["JsonRpcConnection\n(stdio, LSP framing)"]
|
||||
B["JSExtensionWrapper (ext B)"] --> BRPC["JsonRpcConnection\n(stdio, LSP framing)"]
|
||||
N["... one process per extension"]
|
||||
end
|
||||
|
||||
W["WinRTExtensionService\n(existing COM/WinRT extensions)"]
|
||||
BI["BuiltInExtensionService\n(built-in extensions)"]
|
||||
end
|
||||
|
||||
ARPC <-->|"JSON-RPC 2.0 over stdio"| NODEA["node ext-a.js\n(TS SDK runtime)"]
|
||||
BRPC <-->|"JSON-RPC 2.0 over stdio"| NODEB["node ext-b.js\n(TS SDK runtime)"]
|
||||
```
|
||||
|
||||
Each JS extension runs in its own Node.js process. The host spawns the process, establishes a JSON-RPC 2.0 connection over stdin/stdout with LSP-style `Content-Length` framing, sends an `initialize` request, and then queries the extension for commands, pages, and content as the user navigates.
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Process model | One Node.js process per extension | Isolation, independent crash recovery, independent debugging |
|
||||
| Transport | stdio with LSP framing | No port conflicts, no network exposure, proven by LSP ecosystem |
|
||||
| Protocol | JSON-RPC 2.0 | Standard, well-tooled, bidirectional |
|
||||
| SDK language | TypeScript | Type safety, npm ecosystem, familiar to web developers |
|
||||
| Entry point | `cmdpal` field in `package.json` | Simple, declarative, same pattern as VS Code's contributions |
|
||||
| Icon data | Base64-encoded in JSON | No filesystem sharing needed, works with generated/fetched images |
|
||||
| Hot-reload | FileSystemWatcher on the extension directory | Immediate feedback during development |
|
||||
|
||||
---
|
||||
|
||||
## Feedback Requested
|
||||
|
||||
We are seeking feedback on the following areas:
|
||||
|
||||
1. **API surface.** Are the base classes and types intuitive? What is missing?
|
||||
2. **Extension lifecycle.** Is the initialize, query, dispose model sufficient?
|
||||
3. **Manifest schema.** What additional fields would be useful?
|
||||
4. **Distribution.** Should we support npm-based installation? Local-only? Both?
|
||||
5. **Security.** What permission boundaries should exist for JS extensions?
|
||||
6. **Developer experience.** What tooling (CLI scaffolding, debugging, testing) is most important?
|
||||
7. **Performance.** Are there concerns about per-extension Node.js processes?
|
||||
|
||||
Please file issues with the tag `[CmdPal-JS-SDK]`.
|
||||
4
src/modules/cmdpal/ext/SampleJSExtension/.gitignore
vendored
Normal file
4
src/modules/cmdpal/ext/SampleJSExtension/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Build output and dependencies are not checked in.
|
||||
dist/
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
105
src/modules/cmdpal/ext/SampleJSExtension/README.md
Normal file
105
src/modules/cmdpal/ext/SampleJSExtension/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Sample JS/TS Command Palette Extension
|
||||
|
||||
A JavaScript/TypeScript parity sample for the PowerToys Command Palette (CmdPal).
|
||||
It mirrors the built-in C# `SamplePagesExtension` page for page using the
|
||||
TypeScript SDK, `@microsoft/cmdpal-sdk`. Use it as a reference for building your
|
||||
own JS/TS extension and for manually validating the JS/TS extension host.
|
||||
|
||||
JS/TS extensions run as an isolated Node.js process that talks to CmdPal over
|
||||
JSON-RPC 2.0 on stdio. See the spec under
|
||||
`src/modules/cmdpal/doc/json-rpc-spec/` for the full architecture.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
Each sample mirrors the matching C# page. Titles, subtitles, section names,
|
||||
tags, and command behavior match the C# sample as closely as the JS SDK allows:
|
||||
|
||||
- List page with tags, links, a nested (multi-level) context menu, confirmation
|
||||
dialogs, and status messages.
|
||||
- Toast notification samples (`showToast` results, including a custom message).
|
||||
- List page with details (markdown body, tags, links, a local hero image, and
|
||||
command metadata).
|
||||
- Live updating details.
|
||||
- List pages with sections (list and grid variants).
|
||||
- List page with items that change on a timer.
|
||||
- Dynamic list page that rebuilds items from the query, with filters.
|
||||
- Grid and gallery layouts.
|
||||
- OnLoad demo.
|
||||
- Icon page covering many icon-string forms.
|
||||
- Slow loading list page.
|
||||
- Prefix suggestions (`@` people, `/` commands).
|
||||
- Content pages: mixed markdown plus form, plain text, image, and nested tree.
|
||||
- Nested comments built from tree plus form content.
|
||||
- Markdown pages: single block, many blocks, with details, and with images.
|
||||
- Settings page built with the settings helpers.
|
||||
- Clipboard demo.
|
||||
|
||||
### Capabilities intentionally not mirrored
|
||||
|
||||
Some C# capabilities are not yet exposed by the JS SDK or the JSON-RPC protocol.
|
||||
They are omitted here, or approximated with a clear code comment, rather than
|
||||
inventing protocol methods:
|
||||
|
||||
- Dock bands (`SampleDockBand`, `SampleButtonsDockBand`). No protocol surface.
|
||||
- Parameter pages (`SimpleParameterTest`, `ButtonParameterTest`,
|
||||
`MixedParamTestPage`) and the create-note list-parameter page. No parameter
|
||||
run protocol.
|
||||
- Drag and drop via `DataPackage`. `IListItem` has no `DataPackage`, so the
|
||||
clipboard demo copies to the clipboard instead.
|
||||
- Toast icon and toast action button (`IToastArgs2`). `ToastArgs` carries a
|
||||
message and an optional follow-up result only.
|
||||
- Details size (Small/Medium/Large). The JS `Details` type has no size, so the
|
||||
variants collapse to the default.
|
||||
- Live-updating details through targeted property change. Approximated with a
|
||||
dynamic page that refreshes items on a timer.
|
||||
- Win32 foreground-window and other in-process host tricks.
|
||||
- Evil samples and issue-specific host-ABI repros.
|
||||
|
||||
## Build
|
||||
|
||||
The sample depends on the local SDK through a `file:` dependency, so the SDK
|
||||
must be built first.
|
||||
|
||||
1. Build the SDK once:
|
||||
|
||||
```powershell
|
||||
cd src\modules\cmdpal\ts-sdk
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Build the sample:
|
||||
|
||||
```powershell
|
||||
cd src\modules\cmdpal\ext\SampleJSExtension
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
`npm run build` compiles `src\*.ts` to `dist\` and copies the `assets\` folder
|
||||
(which holds the details hero image) to `dist\assets\`. Only source is
|
||||
committed; `dist\` and `node_modules\` are git-ignored.
|
||||
|
||||
## Sideload for manual validation
|
||||
|
||||
CmdPal discovers JS/TS extensions under:
|
||||
|
||||
```
|
||||
%LOCALAPPDATA%\Microsoft\PowerToys\CmdPal\JSExtensions\<name>\
|
||||
```
|
||||
|
||||
A discovered extension folder must contain `package.json` (with the `cmdpal`
|
||||
section and a `main` that resolves to a built file), the compiled `dist\`, and
|
||||
its `node_modules\`. To sideload this sample after building it:
|
||||
|
||||
```powershell
|
||||
$dest = "$env:LOCALAPPDATA\Microsoft\PowerToys\CmdPal\JSExtensions\SampleJSExtension"
|
||||
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
||||
Copy-Item package.json, dist, node_modules -Destination $dest -Recurse -Force
|
||||
```
|
||||
|
||||
Then open Command Palette. The provider appears as "Sample Pages Commands (JS)"
|
||||
with a top-level "Sample Pages (JS)" command that opens the samples index.
|
||||
|
||||
To remove it, delete the `SampleJSExtension` folder from `JSExtensions` and
|
||||
reload.
|
||||
BIN
src/modules/cmdpal/ext/SampleJSExtension/assets/hero.png
Normal file
BIN
src/modules/cmdpal/ext/SampleJSExtension/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
76
src/modules/cmdpal/ext/SampleJSExtension/package-lock.json
generated
Normal file
76
src/modules/cmdpal/ext/SampleJSExtension/package-lock.json
generated
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "sample-js-extension",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sample-js-extension",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@microsoft/cmdpal-sdk": "file:../../ts-sdk"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.0",
|
||||
"typescript": "^5.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"../../ts-sdk": {
|
||||
"name": "@microsoft/cmdpal-sdk",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@types/node": "^22.7.0",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.8.0",
|
||||
"typescript-eslint": "^8.10.0",
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/cmdpal-sdk": {
|
||||
"resolved": "../../ts-sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.0",
|
||||
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/node/-/node-22.20.0.tgz",
|
||||
"integrity": "sha1-Qx9QBzlrwaGke5x99g8+XgtbcwQ=",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha1-W09Z4VMQqxeiFvXWz1PuR27eZw8=",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha1-aR0ArzkJvpOn+qE75hs6W1DvEss=",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/modules/cmdpal/ext/SampleJSExtension/package.json
Normal file
38
src/modules/cmdpal/ext/SampleJSExtension/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "sample-js-extension",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "A JavaScript/TypeScript parity sample for the PowerToys Command Palette that mirrors the C# SamplePagesExtension using the @microsoft/cmdpal-sdk.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"cmdpal": {
|
||||
"displayName": "Sample Pages Commands (JS)",
|
||||
"publisher": "Microsoft Corporation",
|
||||
"icon": "\uE82D"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "node -e \"const{rmSync}=require('node:fs');rmSync('dist',{recursive:true,force:true})\"",
|
||||
"build": "tsc -p tsconfig.json && node -e \"const{cpSync}=require('node:fs');cpSync('assets','dist/assets',{recursive:true})\"",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"prepack": "npm run clean && npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"powertoys",
|
||||
"command-palette",
|
||||
"cmdpal",
|
||||
"extension",
|
||||
"sample"
|
||||
],
|
||||
"author": "Microsoft Corporation",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/cmdpal-sdk": "file:../../ts-sdk"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// 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.
|
||||
|
||||
import { ExtensionHost, InvokableCommandBase } from '@microsoft/cmdpal-sdk';
|
||||
import type { CommandResult, MessageState } from '@microsoft/cmdpal-sdk';
|
||||
|
||||
/**
|
||||
* Shows a toast in the transparent toast window by returning a `showToast`
|
||||
* command result. Mirrors the C# `ShowToastCommand`.
|
||||
*/
|
||||
export class ShowToastCommand extends InvokableCommandBase {
|
||||
readonly id: string;
|
||||
readonly name = 'Show toast';
|
||||
|
||||
private readonly message: string;
|
||||
|
||||
constructor(message: string, id = 'show-toast') {
|
||||
super();
|
||||
this.id = id;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
override invoke(): CommandResult {
|
||||
return { kind: 'showToast', args: { message: this.message } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an in-page status message through the host bridge and keeps the palette
|
||||
* open. Mirrors the C# `ToastCommand`, which uses `ToastStatusMessage` (an
|
||||
* inline banner) rather than the toast window.
|
||||
*/
|
||||
export class StatusMessageCommand extends InvokableCommandBase {
|
||||
readonly id: string;
|
||||
name: string;
|
||||
|
||||
private readonly message: string;
|
||||
private readonly state: MessageState;
|
||||
|
||||
constructor(message: string, state: MessageState = 'info', id = `status:${message}`) {
|
||||
super();
|
||||
this.id = id;
|
||||
this.name = 'Show status';
|
||||
this.message = message;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
override invoke(): CommandResult {
|
||||
ExtensionHost.showStatus(this.message, this.state);
|
||||
return { kind: 'keepOpen' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycles through the four message states each time it is invoked. Mirrors the
|
||||
* C# `SendMessageCommand`.
|
||||
*/
|
||||
export class SendMessageCommand extends InvokableCommandBase {
|
||||
readonly id = 'send-message';
|
||||
readonly name = 'Send message';
|
||||
|
||||
private sentMessages = 0;
|
||||
|
||||
override invoke(): CommandResult {
|
||||
const states: MessageState[] = ['info', 'success', 'warning', 'error'];
|
||||
const state = states[this.sentMessages % states.length] ?? 'info';
|
||||
ExtensionHost.showStatus(`I am status message no.${this.sentMessages}`, state);
|
||||
this.sentMessages += 1;
|
||||
return { kind: 'keepOpen' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a single status message and hides it again on the next invoke. Mirrors
|
||||
* the C# `SingleMessageCommand`.
|
||||
*/
|
||||
export class SingleMessageCommand extends InvokableCommandBase {
|
||||
readonly id = 'single-message';
|
||||
name = 'Show';
|
||||
|
||||
// The stable handle the host minted for the shown status. Hiding targets this
|
||||
// exact status rather than matching on the message text.
|
||||
private statusId: string | null = null;
|
||||
|
||||
get isShown(): boolean {
|
||||
return this.statusId !== null;
|
||||
}
|
||||
|
||||
override invoke(): CommandResult {
|
||||
if (this.statusId !== null) {
|
||||
ExtensionHost.hideStatus(this.statusId);
|
||||
this.statusId = null;
|
||||
} else {
|
||||
this.statusId = ExtensionHost.showStatus('I am a status message', 'info');
|
||||
}
|
||||
|
||||
this.name = this.statusId !== null ? 'Hide' : 'Show';
|
||||
return { kind: 'keepOpen' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an indeterminate progress status, then resolves it in place to a
|
||||
* completion message after a short delay. Mirrors the C#
|
||||
* `IndeterminateProgressMessageCommand`.
|
||||
*/
|
||||
export class IndeterminateProgressMessageCommand extends InvokableCommandBase {
|
||||
readonly id = 'indeterminate-progress';
|
||||
readonly name = 'Do the thing';
|
||||
|
||||
private running = false;
|
||||
|
||||
override invoke(): CommandResult {
|
||||
if (!this.running) {
|
||||
this.running = true;
|
||||
|
||||
// Keep the stable status handle the host returns. The working status is
|
||||
// updated in place to the completion message and then hidden by the same
|
||||
// id, so the spinner is never left behind next to the result.
|
||||
const statusId = ExtensionHost.showStatus('Doing the thing...', 'info', { isIndeterminate: true });
|
||||
setTimeout(() => {
|
||||
ExtensionHost.updateStatus(statusId, 'Did the thing!', 'success');
|
||||
setTimeout(() => {
|
||||
ExtensionHost.hideStatus(statusId);
|
||||
this.running = false;
|
||||
}, 3000);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return { kind: 'keepOpen' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an indeterminate progress status and then resolves it in place to a
|
||||
* success message after a short delay. Used by the details command buttons to
|
||||
* make the status banner and its spinner obviously visible when the button is
|
||||
* invoked.
|
||||
*/
|
||||
export class ProgressStatusCommand extends InvokableCommandBase {
|
||||
readonly id: string;
|
||||
name: string;
|
||||
|
||||
private readonly workingMessage: string;
|
||||
private readonly doneMessage: string;
|
||||
private running = false;
|
||||
|
||||
constructor(name: string, workingMessage: string, doneMessage: string, id: string) {
|
||||
super();
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.workingMessage = workingMessage;
|
||||
this.doneMessage = doneMessage;
|
||||
}
|
||||
|
||||
override invoke(): CommandResult {
|
||||
if (!this.running) {
|
||||
this.running = true;
|
||||
|
||||
// Update the working status in place to the completion message, then hide
|
||||
// that same status, instead of stacking a second banner on top of it.
|
||||
const statusId = ExtensionHost.showStatus(this.workingMessage, 'info', { isIndeterminate: true });
|
||||
setTimeout(() => {
|
||||
ExtensionHost.updateStatus(statusId, this.doneMessage, 'success');
|
||||
setTimeout(() => {
|
||||
ExtensionHost.hideStatus(statusId);
|
||||
this.running = false;
|
||||
}, 3000);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
return { kind: 'keepOpen' };
|
||||
}
|
||||
}
|
||||
35
src/modules/cmdpal/ext/SampleJSExtension/src/index.ts
Normal file
35
src/modules/cmdpal/ext/SampleJSExtension/src/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// 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.
|
||||
|
||||
import { CommandItemBase, CommandProviderBase, run } from '@microsoft/cmdpal-sdk';
|
||||
import type { ICommandItem } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from './util.js';
|
||||
import { SamplesListPage } from './samplesListPage.js';
|
||||
|
||||
/**
|
||||
* The provider for the JavaScript sample extension. Mirrors the C#
|
||||
* `SamplePagesCommandsProvider`, exposing a single top-level "Sample Pages"
|
||||
* command that opens the {@link SamplesListPage} index.
|
||||
*/
|
||||
class SampleProvider extends CommandProviderBase {
|
||||
readonly id = 'SampleJSExtension';
|
||||
readonly displayName = 'Sample Pages Commands (JS)';
|
||||
|
||||
override icon = icon('\uE82D');
|
||||
|
||||
private readonly samplesPage = new SamplesListPage();
|
||||
|
||||
override topLevelCommands(): ICommandItem[] {
|
||||
return [
|
||||
new CommandItemBase({
|
||||
command: this.samplesPage,
|
||||
title: 'Sample Pages (JS)',
|
||||
subtitle: 'View example commands',
|
||||
icon: icon('\uE82D'),
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
run(() => new SampleProvider());
|
||||
67
src/modules/cmdpal/ext/SampleJSExtension/src/liveRefresh.ts
Normal file
67
src/modules/cmdpal/ext/SampleJSExtension/src/liveRefresh.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Drives periodic refresh notifications for the live-updating sample pages while
|
||||
* a page is actually being observed by the host.
|
||||
*
|
||||
* The JS protocol has no page unload signal, so a plain `setInterval` started in
|
||||
* `getItems()` would run for the life of the process and keep sending refresh
|
||||
* traffic long after the user navigated away. It would also leave one timer
|
||||
* behind for every page instance that was ever visited.
|
||||
*
|
||||
* This helper avoids both problems. A page calls {@link LiveRefresh.observe} at
|
||||
* the top of `getItems()`. That records the moment of the fetch and arms a
|
||||
* single timer if one is not already running. On each tick the timer checks how
|
||||
* long it has been since the last fetch: while the page is on screen the host
|
||||
* keeps re-fetching in response to the notifications, so the gap stays small and
|
||||
* the timer keeps ticking. Once the page is no longer visible the host stops
|
||||
* fetching, the gap grows past the idle threshold, and the timer stops itself.
|
||||
* The next `observe` call (when the user navigates back) arms it again. At most
|
||||
* one timer runs per page, and only while that page is being viewed.
|
||||
*/
|
||||
export class LiveRefresh {
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private lastFetch = 0;
|
||||
|
||||
/**
|
||||
* @param intervalMs How often to fire while the page is being observed.
|
||||
* @param onTick Invoked on each tick to update state and notify the host.
|
||||
*/
|
||||
constructor(
|
||||
private readonly intervalMs: number,
|
||||
private readonly onTick: () => void,
|
||||
) {}
|
||||
|
||||
/** Marks the page as observed and arms the refresh timer if it is not running. */
|
||||
observe(): void {
|
||||
this.lastFetch = Date.now();
|
||||
if (this.timer !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A missed fetch cycle means the host stopped asking for items, which happens
|
||||
// when the page is no longer visible. Allow a little slack before stopping.
|
||||
const idleThreshold = this.intervalMs * 3;
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
if (Date.now() - this.lastFetch > idleThreshold) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
this.onTick();
|
||||
}, this.intervalMs);
|
||||
|
||||
// Do not keep the Node.js process alive solely for this timer.
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
private stop(): void {
|
||||
if (this.timer !== undefined) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
138
src/modules/cmdpal/ext/SampleJSExtension/src/markdownText.ts
Normal file
138
src/modules/cmdpal/ext/SampleJSExtension/src/markdownText.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* The rendered Markdown guide shared by the markdown sample pages. This mirrors
|
||||
* `SampleMarkdownPage.SampleMarkdownText` from the C# SamplePagesExtension.
|
||||
*/
|
||||
export const sampleMarkdownText = `
|
||||
# Markdown Guide
|
||||
|
||||
Markdown is a lightweight markup language with plain text formatting syntax. It's often used to format readme files, for writing messages in online forums, and to create rich text using a simple, plain text editor.
|
||||
|
||||
## Basic Markdown Formatting
|
||||
|
||||
### Headings
|
||||
|
||||
# This is an <h1> tag
|
||||
## This is an <h2> tag
|
||||
### This is an <h3> tag
|
||||
#### This is an <h4> tag
|
||||
##### This is an <h5> tag
|
||||
###### This is an <h6> tag
|
||||
|
||||
### Emphasis
|
||||
|
||||
*This text will be italic*
|
||||
_This will also be italic_
|
||||
|
||||
**This text will be bold**
|
||||
__This will also be bold__
|
||||
|
||||
_You **can** combine them_
|
||||
|
||||
Result:
|
||||
|
||||
*This text will be italic*
|
||||
|
||||
_This will also be italic_
|
||||
|
||||
**This text will be bold**
|
||||
|
||||
__This will also be bold__
|
||||
|
||||
_You **can** combine them_
|
||||
|
||||
### Lists
|
||||
|
||||
**Unordered:**
|
||||
|
||||
* Milk
|
||||
* Bread
|
||||
* Whole grain
|
||||
* Butter
|
||||
|
||||
Result:
|
||||
|
||||
* Milk
|
||||
* Bread
|
||||
* Whole grain
|
||||
* Butter
|
||||
|
||||
**Ordered:**
|
||||
|
||||
1. Tidy the kitchen
|
||||
2. Prepare ingredients
|
||||
3. Cook delicious things
|
||||
|
||||
Result:
|
||||
|
||||
1. Tidy the kitchen
|
||||
2. Prepare ingredients
|
||||
3. Cook delicious things
|
||||
|
||||
### Links
|
||||
|
||||
[example](http://example.com)
|
||||
|
||||
Result:
|
||||
|
||||
[example](http://example.com)
|
||||
|
||||
### Blockquotes
|
||||
|
||||
As Albert Einstein said:
|
||||
|
||||
> If we knew what it was we were doing,
|
||||
> it would not be called research, would it?
|
||||
|
||||
Result:
|
||||
|
||||
As Albert Einstein said:
|
||||
|
||||
> If we knew what it was we were doing,
|
||||
> it would not be called research, would it?
|
||||
|
||||
### Horizontal Rules
|
||||
|
||||
\`\`\`markdown
|
||||
---
|
||||
\`\`\`
|
||||
|
||||
Result:
|
||||
|
||||
---
|
||||
|
||||
### Code Snippets
|
||||
|
||||
Indenting by 4 spaces will turn an entire paragraph into a code-block.
|
||||
|
||||
Result:
|
||||
|
||||
.my-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
## Tables
|
||||
|
||||
### Pipe table
|
||||
|
||||
| Right | Left | Default | Center |
|
||||
|------:|:-----|---------|:------:|
|
||||
| 12 | 12 | 12 | 12 |
|
||||
| 123 | 123 | 123 | 123 |
|
||||
| 1 | 1 | 1 | 1 |
|
||||
|
||||
## Advanced Markdown
|
||||
|
||||
Note: Some syntax which is not standard to native Markdown. They're extensions of the language.
|
||||
|
||||
### Strike-throughs
|
||||
|
||||
~~deleted words~~
|
||||
|
||||
Result:
|
||||
|
||||
~~deleted words~~
|
||||
`;
|
||||
@@ -0,0 +1,162 @@
|
||||
// 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.
|
||||
|
||||
import { ContentPageBase, ExtensionHost } from '@microsoft/cmdpal-sdk';
|
||||
import type { CommandResult, Content, FormContent, TreeContent } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from '../util.js';
|
||||
|
||||
const postTemplate = JSON.stringify({
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
type: 'AdaptiveCard',
|
||||
version: '1.6',
|
||||
body: [{ type: 'TextBlock', text: '${postBody}', wrap: true }],
|
||||
actions: [
|
||||
{
|
||||
type: 'Action.ShowCard',
|
||||
title: '${replyCard.title}',
|
||||
card: {
|
||||
type: 'AdaptiveCard',
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
version: '1.6',
|
||||
body: [
|
||||
{
|
||||
type: 'Container',
|
||||
id: '${replyCard.idPrefix}Properties',
|
||||
items: [
|
||||
{
|
||||
$data: '${replyCard.fields}',
|
||||
type: 'Input.Text',
|
||||
label: '${label}',
|
||||
id: '${id}',
|
||||
isRequired: '${required}',
|
||||
isMultiline: true,
|
||||
errorMessage: "'${label}' is required",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: [{ type: 'Action.Submit', title: 'Post' }],
|
||||
},
|
||||
},
|
||||
{ type: 'Action.Submit', title: 'Favorite' },
|
||||
{ type: 'Action.Submit', title: 'View on web' },
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* A single post in the comment tree. Mirrors the C# `PostContent`/`PostForm`.
|
||||
*
|
||||
* Each post owns a stable `formId` so the host can route a reply submission back
|
||||
* to this exact post even though the post's form lives deep in a lazily authored
|
||||
* tree. The ids are minted once per post instance, and the page keeps its post
|
||||
* instances alive (see `SampleCommentsPage`), so a reply added to a post is
|
||||
* retained on the model.
|
||||
*
|
||||
* Note on refresh: a content page's tree is serialized in full when the host
|
||||
* calls `contentPage/getContent`, and the host does not re-fetch that content in
|
||||
* response to a form submit (the content-page proxy exposes no live
|
||||
* `ItemsChanged` channel, and expanding a tree branch does not re-query the
|
||||
* extension). A reply therefore does not appear in the thread that is already on
|
||||
* screen; it shows up the next time the page's content is loaded, which happens
|
||||
* when the user navigates away and reopens the page. This sample is deliberately
|
||||
* honest about that rather than claiming an on-screen refresh it cannot perform.
|
||||
*/
|
||||
class Post implements TreeContent {
|
||||
readonly type = 'tree';
|
||||
readonly replies: Post[] = [];
|
||||
readonly formId: string;
|
||||
|
||||
constructor(private readonly body: string) {
|
||||
this.formId = `comment-form-${nextPostId()}`;
|
||||
}
|
||||
|
||||
get rootContent(): Content {
|
||||
const dataJson = JSON.stringify({
|
||||
postBody: this.body,
|
||||
replyCard: {
|
||||
title: 'Reply',
|
||||
idPrefix: 'reply',
|
||||
fields: [
|
||||
{ label: 'Reply', id: 'ReplyBody', required: true, placeholder: 'Write a reply here' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const form: FormContent = {
|
||||
type: 'form',
|
||||
formId: this.formId,
|
||||
templateJson: postTemplate,
|
||||
dataJson,
|
||||
submitForm: (inputs: string): CommandResult => {
|
||||
try {
|
||||
const parsed = JSON.parse(inputs) as { ReplyBody?: string };
|
||||
const reply = parsed.ReplyBody;
|
||||
if (reply) {
|
||||
this.replies.push(new Post(reply));
|
||||
// The reply is saved to the model, but the thread already on screen
|
||||
// is not re-fetched (see the class note above), so the status is
|
||||
// honest about when it will be visible instead of implying a live
|
||||
// refresh.
|
||||
ExtensionHost.showStatus('Reply saved. Reopen this page to see it.', 'success');
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed form payloads.
|
||||
}
|
||||
return { kind: 'keepOpen' };
|
||||
},
|
||||
};
|
||||
return form;
|
||||
}
|
||||
|
||||
getChildren(): Content[] {
|
||||
return [...this.replies];
|
||||
}
|
||||
}
|
||||
|
||||
let postCounter = 0;
|
||||
function nextPostId(): number {
|
||||
postCounter += 1;
|
||||
return postCounter;
|
||||
}
|
||||
|
||||
function post(body: string, replies: string[] = []): Post {
|
||||
const p = new Post(body);
|
||||
for (const reply of replies) {
|
||||
p.replies.push(new Post(reply));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A page of nested comment threads. Mirrors the C# `SampleCommentsPage`. */
|
||||
export class SampleCommentsPage extends ContentPageBase {
|
||||
readonly id = 'sample-comments-page';
|
||||
readonly name = 'View Posts';
|
||||
readonly title = 'View Posts';
|
||||
|
||||
override icon = icon('\uE90A');
|
||||
|
||||
private readonly posts: Post[] = [
|
||||
post('First', ["Oh very insightful. I hadn't considered that", 'Second', 'ah the ol switcheroo']),
|
||||
post('First\nEDIT: shoot', ['delete this']),
|
||||
post('Do you think they get the picture', ['Probably! Now go build and be happy']),
|
||||
];
|
||||
|
||||
private readonly tree: TreeContent = {
|
||||
type: 'tree',
|
||||
rootContent: {
|
||||
type: 'markdown',
|
||||
body: [
|
||||
'# Example of a thread of comments',
|
||||
'You can use TreeContent in combination with FormContent to build a structure like a page with comments.',
|
||||
'',
|
||||
'The forms on this page use the AdaptiveCard `Action.ShowCard` action to show a nested, hidden card on the form.',
|
||||
].join('\n'),
|
||||
},
|
||||
getChildren: (): Content[] => [...this.posts],
|
||||
};
|
||||
|
||||
override getContent(): Content[] {
|
||||
return [this.tree];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// 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.
|
||||
|
||||
import { ContentPageBase } from '@microsoft/cmdpal-sdk';
|
||||
import type { CommandResult, Content, FormContent } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from '../util.js';
|
||||
|
||||
const loremIpsum =
|
||||
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.";
|
||||
|
||||
const sampleFormTemplate = JSON.stringify({
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
type: 'AdaptiveCard',
|
||||
version: '1.6',
|
||||
body: [
|
||||
{ type: 'TextBlock', size: 'medium', weight: 'bolder', text: ' ${ParticipantInfoForm.title}', horizontalAlignment: 'center', wrap: true, style: 'heading' },
|
||||
{ type: 'Input.Text', label: 'Name', style: 'text', id: 'SimpleVal', isRequired: true, errorMessage: 'Name is required', placeholder: 'Enter your name' },
|
||||
{ type: 'Input.Text', label: 'Homepage', style: 'url', id: 'UrlVal', placeholder: 'Enter your homepage url' },
|
||||
{ type: 'Input.Text', label: 'Email', style: 'email', id: 'EmailVal', placeholder: 'Enter your email' },
|
||||
{ type: 'Input.Text', label: 'Phone', style: 'tel', id: 'TelVal', placeholder: 'Enter your phone number' },
|
||||
{ type: 'Input.Text', label: 'Comments', style: 'text', isMultiline: true, id: 'MultiLineVal', placeholder: 'Enter any comments' },
|
||||
{ type: 'Input.Number', label: 'Quantity (Minimum -5, Maximum 5)', min: -5, max: 5, value: 1, id: 'NumVal', errorMessage: 'The quantity must be between -5 and 5' },
|
||||
{ type: 'Input.Date', label: 'Due Date', id: 'DateVal', value: '2017-09-20' },
|
||||
{ type: 'Input.Time', label: 'Start time', id: 'TimeVal', value: '16:59' },
|
||||
{ type: 'TextBlock', size: 'medium', weight: 'bolder', text: '${Survey.title} ', horizontalAlignment: 'center', wrap: true, style: 'heading' },
|
||||
{ type: 'Input.ChoiceSet', id: 'CompactSelectVal', label: '${Survey.questions[0].question}', style: 'compact', value: '1', choices: [{ $data: '${Survey.questions[0].items}', title: '${choice}', value: '${value}' }] },
|
||||
{ type: 'Input.ChoiceSet', id: 'SingleSelectVal', label: '${Survey.questions[1].question}', style: 'expanded', value: '1', choices: [{ $data: '${Survey.questions[1].items}', title: '${choice}', value: '${value}' }] },
|
||||
{ type: 'Input.ChoiceSet', id: 'MultiSelectVal', label: '${Survey.questions[2].question}', isMultiSelect: true, value: '1,3', choices: [{ $data: '${Survey.questions[2].items}', title: '${choice}', value: '${value}' }] },
|
||||
{ type: 'TextBlock', size: 'medium', weight: 'bolder', text: 'Input.Toggle', horizontalAlignment: 'center', wrap: true, style: 'heading' },
|
||||
{ type: 'Input.Toggle', label: 'Please accept the terms and conditions:', title: '${Survey.questions[3].question}', valueOn: 'true', valueOff: 'false', id: 'AcceptsTerms', isRequired: true, errorMessage: 'Accepting the terms and conditions is required' },
|
||||
{ type: 'Input.Toggle', label: 'How do you feel about red cars?', title: '${Survey.questions[4].question}', valueOn: 'RedCars', valueOff: 'NotRedCars', id: 'ColorPreference' },
|
||||
],
|
||||
actions: [
|
||||
{ type: 'Action.Submit', title: 'Submit', data: { id: '1234567890' } },
|
||||
{ type: 'Action.ShowCard', title: 'Show Card', card: { type: 'AdaptiveCard', body: [{ type: 'Input.Text', label: 'Enter comment', style: 'text', id: 'CommentVal' }], actions: [{ type: 'Action.Submit', title: 'OK' }] } },
|
||||
],
|
||||
});
|
||||
|
||||
const sampleFormData = JSON.stringify({
|
||||
ParticipantInfoForm: { title: 'Input.Text elements' },
|
||||
Survey: {
|
||||
title: 'Input ChoiceSet',
|
||||
questions: [
|
||||
{ question: 'What color do you want? (compact)', items: [{ choice: 'Red', value: '1' }, { choice: 'Green', value: '2' }, { choice: 'Blue', value: '3' }] },
|
||||
{ question: 'What color do you want? (expanded)', items: [{ choice: 'Red', value: '1' }, { choice: 'Green', value: '2' }, { choice: 'Blue', value: '3' }] },
|
||||
{ question: 'What color do you want? (multiselect)', items: [{ choice: 'Red', value: '1' }, { choice: 'Green', value: '2' }, { choice: 'Blue', value: '3' }] },
|
||||
{ question: 'I accept the terms and conditions (True/False)' },
|
||||
{ question: 'Red cars are better than other cars' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
function sampleContentForm(): FormContent {
|
||||
return {
|
||||
type: 'form',
|
||||
templateJson: sampleFormTemplate,
|
||||
dataJson: sampleFormData,
|
||||
submitForm(): CommandResult {
|
||||
return { kind: 'goHome' };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A page mixing markdown and a form. Mirrors the C# `SampleContentPage`. */
|
||||
export class SampleContentPage extends ContentPageBase {
|
||||
readonly id = 'sample-content-page';
|
||||
readonly name = 'Open';
|
||||
readonly title = 'Sample Content';
|
||||
|
||||
override icon = icon('\uECA5');
|
||||
|
||||
override getContent(): Content[] {
|
||||
return [
|
||||
{ type: 'markdown', body: '# Sample page with mixed content \n This page has both markdown, and form content' },
|
||||
sampleContentForm(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** A page of plain text content. Mirrors the C# `SamplePlainTextContentPage`. */
|
||||
export class SamplePlainTextContentPage extends ContentPageBase {
|
||||
readonly id = 'sample-plain-text-content-page';
|
||||
readonly name = 'Plain Text';
|
||||
readonly title = 'Sample Plain Text Content';
|
||||
|
||||
override icon = icon('\uE8D2');
|
||||
|
||||
override getContent(): Content[] {
|
||||
return [
|
||||
{
|
||||
type: 'plainText',
|
||||
text: `# Sample Plain Text Content\nThis is a sample plain text content page.\n\nYou can right-click the content and switch wrap mode on or off, or change the font.\n\n${loremIpsum}`,
|
||||
},
|
||||
{
|
||||
type: 'plainText',
|
||||
text: `# Sample Plain Text Content\nThis is a sample plain text content page. This one is monospace and wraps by default.\n\nYou can right-click the content and switch wrap mode on or off, or change the font.\n\n${loremIpsum}`,
|
||||
fontFamily: 'monospace',
|
||||
wrapWords: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A page showing images. Mirrors the C# `SampleImageContentPage`.
|
||||
*
|
||||
* Approximation: the C# page loads packaged JPG and SVG assets. This sample
|
||||
* ships no binary assets, so a web-hosted image URL stands in.
|
||||
*/
|
||||
export class SampleImageContentPage extends ContentPageBase {
|
||||
readonly id = 'sample-image-content-page';
|
||||
readonly name = 'Image';
|
||||
readonly title = 'Sample Image Content';
|
||||
|
||||
override icon = icon('\uE722');
|
||||
|
||||
override getContent(): Content[] {
|
||||
const image = icon(
|
||||
'https://raw.githubusercontent.com/microsoft/PowerToys/main/doc/images/Logo.png',
|
||||
);
|
||||
return [
|
||||
{ type: 'image', image },
|
||||
{ type: 'image', image, maxWidth: 200, maxHeight: 200 },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** A page with a tree of nested content. Mirrors the C# `SampleTreeContentPage`. */
|
||||
export class SampleTreeContentPage extends ContentPageBase {
|
||||
readonly id = 'sample-tree-content-page';
|
||||
readonly name = 'Sample Content';
|
||||
readonly title = 'Sample Content';
|
||||
|
||||
override icon = icon('\uE81E');
|
||||
|
||||
override getContent(): Content[] {
|
||||
const nestedForm: FormContent = {
|
||||
type: 'form',
|
||||
// A stable formId lets the host route this form's submit back to this
|
||||
// handler even though it is nested several levels deep in a tree whose
|
||||
// children are produced lazily. Without it the form would rely on the
|
||||
// serializer's positional fallback id, which is not stable when only part
|
||||
// of the tree has been expanded.
|
||||
formId: 'tree-nested-form',
|
||||
templateJson: JSON.stringify({
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
type: 'AdaptiveCard',
|
||||
version: '1.6',
|
||||
body: [
|
||||
{ type: 'TextBlock', size: 'medium', weight: 'bolder', text: "Mix and match why don't you", horizontalAlignment: 'center', wrap: true, style: 'heading' },
|
||||
{ type: 'TextBlock', text: 'You can have forms here too', horizontalAlignment: 'Right', wrap: true },
|
||||
],
|
||||
actions: [{ type: 'Action.Submit', title: "It's a form, you get it", data: { id: 'LoginVal' } }],
|
||||
}),
|
||||
dataJson: '{}',
|
||||
submitForm(): CommandResult {
|
||||
return { kind: 'goHome' };
|
||||
},
|
||||
};
|
||||
|
||||
const tree: Content = {
|
||||
type: 'tree',
|
||||
rootContent: { type: 'markdown', body: '# This page has nested content' },
|
||||
getChildren(): Content[] {
|
||||
return [
|
||||
{
|
||||
type: 'tree',
|
||||
rootContent: { type: 'markdown', body: 'Yo dog' },
|
||||
getChildren(): Content[] {
|
||||
return [
|
||||
{
|
||||
type: 'tree',
|
||||
rootContent: { type: 'markdown', body: 'I heard you like content' },
|
||||
getChildren(): Content[] {
|
||||
return [
|
||||
{ type: 'markdown', body: 'So we put content in your content' },
|
||||
nestedForm,
|
||||
{ type: 'markdown', body: 'Another markdown down here' },
|
||||
];
|
||||
},
|
||||
},
|
||||
{ type: 'markdown', body: '**slaps roof**' },
|
||||
{ type: 'markdown', body: 'This baby can fit so much content' },
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
return [tree];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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.
|
||||
|
||||
import { CopyTextCommand, ListItemBase, ListPageBase, NoOpCommand } from '@microsoft/cmdpal-sdk';
|
||||
import type { IListItem } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from '../util.js';
|
||||
|
||||
/**
|
||||
* A demo of clipboard integration. Mirrors the C# `SampleDataTransferPage`.
|
||||
*
|
||||
* Not-yet-supported: the C# page attaches a `DataPackage` to each list item to
|
||||
* enable drag and drop (including delayed and image payloads). `IListItem` in
|
||||
* the JS protocol has no `DataPackage`, so drag and drop is omitted and the
|
||||
* text items expose a copy-to-clipboard command instead.
|
||||
*/
|
||||
export class SampleDataTransferPage extends ListPageBase {
|
||||
readonly id = 'sample-data-transfer-page';
|
||||
readonly name = 'Open';
|
||||
readonly title = 'Clipboard and Drag-and-Drop Demo';
|
||||
|
||||
override icon = icon('\uE8C8');
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
return [
|
||||
new ListItemBase({
|
||||
command: new CopyTextCommand('Text data in the Data Package', 'Copy text', 'Copied text'),
|
||||
title: 'Item with plain text',
|
||||
subtitle: 'Copy plain text to the clipboard (drag and drop is not supported from JS)',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new CopyTextCommand(new Date().toLocaleString(), 'Copy timestamp', 'Copied timestamp'),
|
||||
title: 'Item with a lazily rendered plain text',
|
||||
subtitle: 'The C# sample renders this lazily on drag; here it is copied when invoked',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('data-transfer-image'),
|
||||
title: 'Item with an image',
|
||||
subtitle: 'The C# sample drags a bitmap and a file; image payloads are not supported from JS',
|
||||
icon: icon('\uEB9F'),
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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.
|
||||
|
||||
import { ListItemBase, ListPageBase, NoOpCommand } from '@microsoft/cmdpal-sdk';
|
||||
import type { DetailsElement, IListItem } from '@microsoft/cmdpal-sdk';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { icon, randomColor, rgb, tag } from '../util.js';
|
||||
import { sampleMarkdownText } from '../markdownText.js';
|
||||
import { ProgressStatusCommand, StatusMessageCommand } from '../commands/statusCommands.js';
|
||||
|
||||
/**
|
||||
* Absolute path to the hero image that ships with the sample. The build copies
|
||||
* `assets/` into `dist/assets/`, so this file sits next to the compiled output
|
||||
* at `dist/assets/hero.png`. Resolving it from `import.meta.url` keeps the path
|
||||
* relative to wherever the extension is installed, and the host's icon loader
|
||||
* resolves an absolute file path the same way it resolves a glyph or URL. This
|
||||
* avoids depending on a network fetch to render the hero image.
|
||||
*/
|
||||
const heroImagePath = fileURLToPath(new URL('../assets/hero.png', import.meta.url));
|
||||
|
||||
/**
|
||||
* Builds the shared "metadata" rows demonstrated in both the details page and
|
||||
* the markdown-with-details page.
|
||||
*
|
||||
* Note: `DetailsLink` requires both a `link` and a `text` in the JS protocol.
|
||||
* The C# sample allows text-only or link-only rows; here an empty string is
|
||||
* used for the missing half.
|
||||
*/
|
||||
export function sampleMetadata(): DetailsElement[] {
|
||||
return [
|
||||
{ key: 'Plain text', data: { type: 'link', link: '', text: 'Set just the text to get text metadata' } },
|
||||
{
|
||||
key: 'Links',
|
||||
data: { type: 'link', link: 'https://github.com/microsoft/PowerToys', text: 'Or metadata can be links' },
|
||||
},
|
||||
{
|
||||
key: 'CmdPal will display the URL if no text is given',
|
||||
data: { type: 'link', link: 'https://github.com/microsoft/PowerToys', text: '' },
|
||||
},
|
||||
{ key: 'Above a separator', data: { type: 'link', link: '', text: 'Below me is a separator' } },
|
||||
{ key: '', data: { type: 'separator' } },
|
||||
{ key: 'Below a separator', data: { type: 'link', link: '', text: 'Above me is a separator' } },
|
||||
{
|
||||
key: 'Add Tags too',
|
||||
data: {
|
||||
type: 'tags',
|
||||
tags: [
|
||||
tag('simple text'),
|
||||
{ text: 'Colored text', foreground: rgb(255, 0, 0) },
|
||||
{ text: 'Colored backgrounds', background: rgb(0, 0, 255) },
|
||||
{ text: 'Colored everything', foreground: rgb(255, 255, 0), background: rgb(0, 0, 255) },
|
||||
{ text: 'Icons too', icon: icon('\uE735'), foreground: rgb(255, 255, 0) },
|
||||
{ text: '', icon: icon('https://i.imgur.com/t9qgDTM.png') },
|
||||
{ text: 'this', foreground: randomColor(), background: randomColor() },
|
||||
{ text: 'baby', foreground: randomColor(), background: randomColor() },
|
||||
{ text: 'can', foreground: randomColor(), background: randomColor() },
|
||||
{ text: 'fit', foreground: randomColor(), background: randomColor() },
|
||||
{ text: 'so', foreground: randomColor(), background: randomColor() },
|
||||
{ text: 'many', foreground: randomColor(), background: randomColor() },
|
||||
{ text: 'tags', foreground: randomColor(), background: randomColor() },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Commands',
|
||||
data: {
|
||||
type: 'commands',
|
||||
commands: [
|
||||
buildProgressButton(
|
||||
'metadata-yes',
|
||||
'Do something amazing',
|
||||
'Doing something amazing...',
|
||||
'You clicked it! The details command button works.',
|
||||
'\uE945',
|
||||
),
|
||||
buildStatusButton(
|
||||
'metadata-no',
|
||||
"Don't click me",
|
||||
'I warned you! The status banner is visible.',
|
||||
'error',
|
||||
'\uEA39',
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildStatusButton(
|
||||
id: string,
|
||||
name: string,
|
||||
message: string,
|
||||
state: 'success' | 'error',
|
||||
glyph: string,
|
||||
): StatusMessageCommand {
|
||||
const command = new StatusMessageCommand(message, state, id);
|
||||
command.name = name;
|
||||
command.icon = icon(glyph);
|
||||
return command;
|
||||
}
|
||||
|
||||
function buildProgressButton(
|
||||
id: string,
|
||||
name: string,
|
||||
workingMessage: string,
|
||||
doneMessage: string,
|
||||
glyph: string,
|
||||
): ProgressStatusCommand {
|
||||
const command = new ProgressStatusCommand(name, workingMessage, doneMessage, id);
|
||||
command.icon = icon(glyph);
|
||||
return command;
|
||||
}
|
||||
|
||||
/**
|
||||
* A list page whose items each show a details pane with markdown, tags, links,
|
||||
* a hero image, and command metadata. Mirrors the C# `SampleListPageWithDetails`.
|
||||
* The hero image is a local asset that ships with the sample, so it renders
|
||||
* without a network connection.
|
||||
*
|
||||
* Not-yet-supported: the JS `Details` type has no `Size` (Small/Medium/Large),
|
||||
* so the C# size variants collapse into the single default size here.
|
||||
*/
|
||||
export class SampleListPageWithDetails extends ListPageBase {
|
||||
readonly id = 'sample-list-page-with-details';
|
||||
readonly name = 'Sample List Page with Details';
|
||||
readonly title = 'Sample List Page with Details';
|
||||
|
||||
override icon = icon('\uE8A0');
|
||||
override showDetails = true;
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
return [
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('details-default'),
|
||||
title: 'Details on ListItems',
|
||||
details: {
|
||||
title: 'This item has default details size',
|
||||
body: 'Each of these items can have a `Body` formatted with **Markdown**',
|
||||
},
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('details-subtitle'),
|
||||
title: 'This one has a subtitle too',
|
||||
subtitle: 'Example Subtitle',
|
||||
details: { title: 'List Item 2', body: sampleMarkdownText },
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('details-tag'),
|
||||
title: 'This one has a tag too',
|
||||
subtitle: 'the one with a tag',
|
||||
tags: [tag('Sample Tag')],
|
||||
details: { title: 'List Item 3', body: '### Example of markdown details' },
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('details-hero'),
|
||||
title: 'This one has a hero image',
|
||||
details: {
|
||||
title: 'Hero Image Example',
|
||||
heroImage: icon(heroImagePath),
|
||||
body: 'It is literally an image of a hero',
|
||||
},
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('details-metadata'),
|
||||
title: 'This one has metadata',
|
||||
subtitle: 'And a details panel',
|
||||
details: {
|
||||
title: 'Metadata Example',
|
||||
body: 'Each of the sections below is some sample metadata',
|
||||
metadata: sampleMetadata(),
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// 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.
|
||||
|
||||
import { DynamicListPageBase, ListItemBase, NoOpCommand } from '@microsoft/cmdpal-sdk';
|
||||
import type { Filters, IListItem } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from '../util.js';
|
||||
|
||||
/**
|
||||
* A dynamic list page that rebuilds its items from the search text and offers
|
||||
* filters. Mirrors the C# `SampleDynamicListPage`.
|
||||
*
|
||||
* The host drives filters through the `listPage/setFilter` request, which the
|
||||
* runtime routes to a `setFilter` method when present.
|
||||
*/
|
||||
export class SampleDynamicListPage extends DynamicListPageBase {
|
||||
readonly id = 'sample-dynamic-list-page';
|
||||
readonly name = 'Dynamic List';
|
||||
readonly title = 'Dynamic List';
|
||||
|
||||
override icon = icon('\uE721');
|
||||
|
||||
override filters: Filters = {
|
||||
currentFilterId: 'all',
|
||||
filters: [
|
||||
{ id: 'all', name: 'All' },
|
||||
{ id: 'mod2', name: 'Every 2nd', icon: icon('2') },
|
||||
{ id: 'mod3', name: 'Every 3rd (and long name)', icon: icon('3') },
|
||||
],
|
||||
};
|
||||
|
||||
override setSearchText(text: string): void {
|
||||
this.searchText = text;
|
||||
this.notifyItemsChanged();
|
||||
}
|
||||
|
||||
setFilter(filterId: string): void {
|
||||
this.filters = { ...this.filters, currentFilterId: filterId };
|
||||
this.notifyItemsChanged();
|
||||
}
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
const chars = [...(this.searchText ?? '')];
|
||||
let items: IListItem[] = chars.map(
|
||||
(ch, index) =>
|
||||
new ListItemBase({ command: new NoOpCommand(`dyn-${index}`), title: ch }),
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
items = [
|
||||
new ListItemBase({ command: new NoOpCommand('dyn-empty'), title: 'Start typing in the search box' }),
|
||||
];
|
||||
}
|
||||
|
||||
switch (this.filters.currentFilterId) {
|
||||
case 'mod2':
|
||||
items = items.filter((_item, index) => (index + 1) % 2 === 0);
|
||||
break;
|
||||
case 'mod3':
|
||||
items = items.filter((_item, index) => (index + 1) % 3 === 0);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const first = items[0];
|
||||
if (first) {
|
||||
first.subtitle =
|
||||
'Notice how the number of items changes for this page when you type in the filter box';
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
103
src/modules/cmdpal/ext/SampleJSExtension/src/pages/gridsPages.ts
Normal file
103
src/modules/cmdpal/ext/SampleJSExtension/src/pages/gridsPages.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// 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.
|
||||
|
||||
import { ListItemBase, ListPageBase, NoOpCommand } from '@microsoft/cmdpal-sdk';
|
||||
import type { GridProperties, IListItem } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from '../util.js';
|
||||
|
||||
let galleryPageCounter = 0;
|
||||
|
||||
/**
|
||||
* A gallery/grid page rendered with a caller-supplied layout. Mirrors the C#
|
||||
* `SampleGalleryListPage`.
|
||||
*
|
||||
* The C# sample decorates its items with bundled image assets. This sample ships
|
||||
* no binary assets, so Segoe Fluent glyphs stand in for the images.
|
||||
*/
|
||||
export class SampleGalleryListPage extends ListPageBase {
|
||||
readonly id: string;
|
||||
readonly name = 'Sample Gallery List Page';
|
||||
readonly title = 'Sample Gallery List Page';
|
||||
|
||||
constructor(gridProperties: GridProperties) {
|
||||
super();
|
||||
this.id = `sample-gallery-${galleryPageCounter++}`;
|
||||
this.gridProperties = gridProperties;
|
||||
}
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
const glyphs = ['\uE753', '\uE8B9', '\uE909', '\uE7F4', '\uE774', '\uE8B9', '\uE909'];
|
||||
const titles = [
|
||||
'Sample Title',
|
||||
'Another Title',
|
||||
'More Titles',
|
||||
'Stop With The Titles',
|
||||
'Another Title',
|
||||
'More Titles',
|
||||
'Stop With The Titles',
|
||||
];
|
||||
return titles.map(
|
||||
(title, index) =>
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand(`gallery-item-${index}`),
|
||||
title,
|
||||
subtitle: "I don't do anything",
|
||||
icon: icon(glyphs[index] ?? '\uE753'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An index of grid and gallery layout variants. Mirrors the C#
|
||||
* `SampleGridsListPage`.
|
||||
*/
|
||||
export class SampleGridsListPage extends ListPageBase {
|
||||
readonly id = 'sample-grids-list-page';
|
||||
readonly name = 'Grid and gallery lists';
|
||||
readonly title = 'Grid and gallery lists';
|
||||
|
||||
override icon = icon('\uE7C5');
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
return [
|
||||
new ListItemBase({
|
||||
command: new SampleGalleryListPage({ type: 'gallery', showTitle: true, showSubtitle: true }),
|
||||
title: 'Gallery list page (title and subtitle)',
|
||||
subtitle: 'A sample gallery list page with images',
|
||||
icon: icon('\uE909'),
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleGalleryListPage({ type: 'gallery', showTitle: true, showSubtitle: false }),
|
||||
title: 'Gallery list page (title, no subtitle)',
|
||||
subtitle: 'A sample gallery list page with images',
|
||||
icon: icon('\uE909'),
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleGalleryListPage({ type: 'gallery', showTitle: false, showSubtitle: false }),
|
||||
title: 'Gallery list page (no title, no subtitle)',
|
||||
subtitle: 'A sample gallery list page with images',
|
||||
icon: icon('\uE909'),
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleGalleryListPage({ type: 'small' }),
|
||||
title: 'Small grid list page',
|
||||
subtitle: 'A sample grid list page with text items',
|
||||
icon: icon('\uE8B9'),
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleGalleryListPage({ type: 'medium', showTitle: true }),
|
||||
title: 'Medium grid (with title)',
|
||||
subtitle: 'A sample grid list page with text items',
|
||||
icon: icon('\uE8B9'),
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleGalleryListPage({ type: 'medium', showTitle: false }),
|
||||
title: 'Medium grid (hidden title)',
|
||||
subtitle: 'A sample grid list page with text items',
|
||||
icon: icon('\uE8B9'),
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
106
src/modules/cmdpal/ext/SampleJSExtension/src/pages/iconPage.ts
Normal file
106
src/modules/cmdpal/ext/SampleJSExtension/src/pages/iconPage.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
import { CopyTextCommand, ListItemBase, ListPageBase } from '@microsoft/cmdpal-sdk';
|
||||
import type { Details, IListItem, Tag } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from '../util.js';
|
||||
|
||||
/*
|
||||
* Quick intro to Unicode in source code:
|
||||
* - Every character has a code point (for example U+0041 = 'A').
|
||||
* - Code points up to U+FFFF use \u1234 (four hex digits).
|
||||
* - Code points above that use \u{XXXXX} in JavaScript source.
|
||||
* - Some symbols (like many emojis) are built from multiple code points joined
|
||||
* together (for example a waving hand plus a skin-tone modifier).
|
||||
*
|
||||
* Mirrors the C# `SampleIconPage`.
|
||||
*/
|
||||
const iconSamples: Array<[string, string, string]> = [
|
||||
['\u{1F60D}', 'Standard emoji icon', 'Basic emoji character rendered as an icon'],
|
||||
['\u{1F60D}\u{1F643}\u{1F622}', 'Multiple emojis', 'Use of multiple emojis for icon is not allowed'],
|
||||
['\u{1F60E}', 'Unicode escape sequence emoji', 'Emoji defined using Unicode escape sequence notation'],
|
||||
['\uE8D4', 'Segoe Fluent icon demonstration', "Segoe Fluent/MDL2 icon from system font\nWorks as an icon but won't display properly in button text"],
|
||||
['\u2328', 'Extended pictographic symbol', 'Pictographic symbol representing a keyboard'],
|
||||
['A', 'Simple text character as icon', 'Basic letter character used as an icon demonstration'],
|
||||
['1', 'Simple text character as icon', 'Basic letter character used as an icon demonstration'],
|
||||
['\u{32}\u{20E3}', 'Emoji without variation selector', "Emoji character doesn't have VS16 variation selector to render as text"],
|
||||
['\u{33}\uFE0F\u{20E3}', 'Emoji with variation selector', 'Emoji character using a variation selector to specify emoji presentation'],
|
||||
['#', 'Simple text character as icon', 'Basic letter character used as an icon demonstration'],
|
||||
['\u0023\uFE0F\u20E3', 'Simple text character as icon', 'Basic letter character used as an icon demonstration'],
|
||||
['WM', 'Invalid icon representation', 'String with multiple characters that does not correspond to a valid single icon'],
|
||||
['\u{1F9D9}', 'Single code-point emoji example', 'Simple emoji character using a single Unicode code point'],
|
||||
['\u{1F9D9}\u200D\u2642\uFE0F', 'Complex emoji with gender modifier', 'Composite emoji using Zero-Width Joiner (ZWJ) sequence for male variant'],
|
||||
['\u{1F9D9}\u200D\u2640\uFE0F', 'Complex emoji with gender modifier', 'Composite emoji using Zero-Width Joiner (ZWJ) sequence for female variant'],
|
||||
['\u{1F44B}', 'Basic hand gesture emoji', 'Standard emoji character representing a waving hand'],
|
||||
['\u{1F44B}\u{1F3FB}', 'Emoji with light skin tone modifier', 'Emoji enhanced with Unicode skin tone modifier (light)'],
|
||||
['\u{1F44B}\u{1F3FF}', 'Emoji with dark skin tone modifier', 'Emoji enhanced with Unicode skin tone modifier (dark)'],
|
||||
['\u{1F1E8}\u{1F1FF}', 'Flag emoji using regional indicators', 'Emoji flag constructed from regional indicator symbols for Czechia'],
|
||||
['\u0995\u09CD\u200D', 'Use of ZWJ in non-emoji context', 'Shows the half-form KA'],
|
||||
['\u0995\u09CD', 'Use of ZWJ in non-emoji context', 'Shows full KA with an explicit virama mark'],
|
||||
['\u{1F004}', 'Mahjong tile emoji (red dragon)', 'Mahjong tile red dragon emoji character using Unicode escape sequence'],
|
||||
['\u{1F005}', 'Mahjong tile non-emoji (green dragon)', 'Mahjong tile character that is not classified as an emoji'],
|
||||
['\u25B6', 'Play symbol (standalone)', 'Play symbol'],
|
||||
['\u25B6\uFE0E', 'Play symbol + VS15 (request text)', 'Play symbol with variation specifier requesting rendering as text'],
|
||||
['\u25B6\uFE0F', 'Play symbol + VS16 (request emoji)', 'Play symbol with variation specifier requesting rendering as emoji'],
|
||||
['\u{23EF}\uFE0F', 'Play/Pause keycap emoji', "Play/Pause keycap emoji doesn't have plain text variant"],
|
||||
['\u{23F8}\uFE0F', 'Pause keycap emoji', "Pause keycap emoji doesn't have plain text variant"],
|
||||
['\u00A9', 'Copyright symbol (standalone)', 'Copyright symbol that is not classified as an emoji'],
|
||||
['\u00A9\uFE0E', 'Copyright symbol + VS15 (request text)', 'Copyright symbol that is not classified as an emoji'],
|
||||
['\u00A9\uFE0F', 'Copyright symbol + VS16 (request emoji)', 'Copyright symbol that is not classified as an emoji'],
|
||||
['\u{1F3F3}\uFE0F', 'White Flag', 'White Flag'],
|
||||
['\u{1F3F4}\u200D\u2620\uFE0F', 'Pirate Flag', 'Pirate Flag'],
|
||||
];
|
||||
|
||||
function codePointTags(value: string): Tag[] {
|
||||
return [...value].map((ch) => {
|
||||
const cp = ch.codePointAt(0) ?? 0;
|
||||
const hex =
|
||||
cp <= 0xffff
|
||||
? `\\u${cp.toString(16).toUpperCase().padStart(4, '0')}`
|
||||
: `\\U${cp.toString(16).toUpperCase().padStart(8, '0')}`;
|
||||
return { text: hex };
|
||||
});
|
||||
}
|
||||
|
||||
function buildIconItem(glyph: string, title: string, description: string): IListItem {
|
||||
const iconInfo = icon(glyph);
|
||||
const details: Details = {
|
||||
heroImage: iconInfo,
|
||||
title,
|
||||
body: description,
|
||||
metadata: [
|
||||
{
|
||||
key: 'Unicode Code Points',
|
||||
data: { type: 'tags', tags: codePointTags(glyph) },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return new ListItemBase({
|
||||
command: new CopyTextCommand(glyph, `Action with ${glyph}`),
|
||||
title,
|
||||
subtitle: description,
|
||||
icon: iconInfo,
|
||||
tags: [{ text: 'Tag', icon: iconInfo }],
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
/** A demo of how many icon strings are interpreted. Mirrors `SampleIconPage`. */
|
||||
export class SampleIconPage extends ListPageBase {
|
||||
readonly id = 'sample-icon-page';
|
||||
readonly name = 'Sample Icon Page';
|
||||
readonly title = 'Sample Icon Page';
|
||||
|
||||
override icon = icon('\uE8BA');
|
||||
override showDetails = true;
|
||||
|
||||
private readonly items = iconSamples.map(([glyph, title, description]) =>
|
||||
buildIconItem(glyph, title, description),
|
||||
);
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
return this.items;
|
||||
}
|
||||
}
|
||||
187
src/modules/cmdpal/ext/SampleJSExtension/src/pages/listPage.ts
Normal file
187
src/modules/cmdpal/ext/SampleJSExtension/src/pages/listPage.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
// 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.
|
||||
|
||||
import {
|
||||
ConfirmableCommand,
|
||||
ListItemBase,
|
||||
ListPageBase,
|
||||
NoOpCommand,
|
||||
OpenUrlCommand,
|
||||
} from '@microsoft/cmdpal-sdk';
|
||||
import type { ContextItem, IListItem, KeyChord } from '@microsoft/cmdpal-sdk';
|
||||
import { icon, tag } from '../util.js';
|
||||
import { SampleMarkdownPage } from './markdownPages.js';
|
||||
import { SampleListPageWithDetails } from './detailsPage.js';
|
||||
import {
|
||||
IndeterminateProgressMessageCommand,
|
||||
SendMessageCommand,
|
||||
SingleMessageCommand,
|
||||
StatusMessageCommand,
|
||||
} from '../commands/statusCommands.js';
|
||||
|
||||
// Modifier bitmask values used by KeyChord: Ctrl = 1, Alt = 2, Shift = 4, Win = 8.
|
||||
const CTRL = 1;
|
||||
|
||||
function keyChord(modifiers: number, vkey: number): KeyChord {
|
||||
return { modifiers, vkey, scanCode: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* A basic list page that demonstrates navigation, links, tags, status
|
||||
* messages, confirmation dialogs, and a nested context menu. Mirrors the C#
|
||||
* `SampleListPage`.
|
||||
*
|
||||
* Not-yet-supported in the JS protocol and therefore omitted here:
|
||||
* - `IExtendedAttributesProvider` command properties (the C# "I have
|
||||
* properties" items).
|
||||
* - The Win32 foreground-window command (no P/Invoke from an isolated Node
|
||||
* process).
|
||||
*/
|
||||
export class SampleListPage extends ListPageBase {
|
||||
readonly id = 'sample-list-page';
|
||||
readonly name = 'Sample List Page';
|
||||
readonly title = 'Sample List Page';
|
||||
|
||||
override icon = icon('\uEA37');
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
const secondCommand = new StatusMessageCommand(
|
||||
'Secondary command invoked',
|
||||
'warning',
|
||||
'ctx-secondary',
|
||||
);
|
||||
secondCommand.name = 'Secondary command';
|
||||
secondCommand.icon = icon('\uF147');
|
||||
|
||||
const thirdCommand = new StatusMessageCommand('Third command invoked', 'error', 'ctx-third');
|
||||
thirdCommand.name = 'Do 3';
|
||||
thirdCommand.icon = icon('\uF148');
|
||||
|
||||
const deeperCommand = new StatusMessageCommand(
|
||||
'Second-level command invoked',
|
||||
'info',
|
||||
'ctx-deeper',
|
||||
);
|
||||
deeperCommand.name = 'A command one level down';
|
||||
deeperCommand.icon = icon('\uF149');
|
||||
|
||||
const deepestCommand = new StatusMessageCommand(
|
||||
'You reached the deepest command',
|
||||
'success',
|
||||
'ctx-deepest',
|
||||
);
|
||||
deepestCommand.name = 'The deepest command';
|
||||
deepestCommand.icon = icon('\uF14A');
|
||||
|
||||
const primaryContext = new StatusMessageCommand(
|
||||
'Primary command invoked',
|
||||
'info',
|
||||
'ctx-primary',
|
||||
);
|
||||
primaryContext.name = 'Primary command';
|
||||
primaryContext.icon = icon('\uF146');
|
||||
|
||||
// `moreCommands` on a context item nests a sub-menu, and each nested item
|
||||
// can nest again. Here "We can go deeper..." opens a second level, which in
|
||||
// turn opens a third, demonstrating recursive context menus end to end.
|
||||
const moreCommands: ContextItem[] = [
|
||||
{
|
||||
command: secondCommand,
|
||||
title: "I'm a second command",
|
||||
requestedShortcut: keyChord(CTRL, 0x31),
|
||||
},
|
||||
{
|
||||
command: thirdCommand,
|
||||
title: 'We can go deeper...',
|
||||
icon: icon('\uF148'),
|
||||
requestedShortcut: keyChord(CTRL, 0x32),
|
||||
moreCommands: [
|
||||
{
|
||||
command: deeperCommand,
|
||||
title: 'Another level down',
|
||||
icon: icon('\uF149'),
|
||||
moreCommands: [
|
||||
{
|
||||
command: deepestCommand,
|
||||
title: 'The deepest level',
|
||||
icon: icon('\uF14A'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const confirmOnce = new ConfirmableCommand({
|
||||
id: 'confirm-once',
|
||||
name: 'Confirm',
|
||||
title: 'You can set a title for the dialog',
|
||||
description: 'Are you really sure you want to do the thing?',
|
||||
primaryCommand: new StatusMessageCommand('The dialog was confirmed', 'info', 'confirmed'),
|
||||
});
|
||||
|
||||
const confirmTwice = new ConfirmableCommand({
|
||||
id: 'confirm-twice',
|
||||
name: 'How sure are you?',
|
||||
title: 'You can ask twice too',
|
||||
description: "You probably don't want to though, that'd be annoying.",
|
||||
primaryCommand: confirmOnce,
|
||||
});
|
||||
|
||||
return [
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('basic-item'),
|
||||
title: 'This is a basic item in the list',
|
||||
subtitle: "I don't do anything though",
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleListPageWithDetails(),
|
||||
title: 'This item will take you to another page',
|
||||
subtitle: 'This allows for nested lists of items',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new OpenUrlCommand('https://github.com/microsoft/powertoys'),
|
||||
title: 'Or you can go to links',
|
||||
subtitle: 'This takes you to the PowerToys repo on GitHub',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleMarkdownPage(),
|
||||
title: 'Items can have tags',
|
||||
subtitle: "and I'll take you to a page with markdown content",
|
||||
tags: [tag('Sample Tag')],
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: primaryContext,
|
||||
title: 'You can add context menu items too. Press Ctrl+K',
|
||||
subtitle: 'Try pressing Ctrl+1 with me selected',
|
||||
icon: icon('\uE712'),
|
||||
moreCommands,
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SendMessageCommand(),
|
||||
title: 'I send lots of messages',
|
||||
subtitle: 'Status messages can be used to provide feedback to the user in-app',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SingleMessageCommand(),
|
||||
title: 'I send a single message',
|
||||
subtitle: 'This demonstrates both showing and hiding a single message',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new IndeterminateProgressMessageCommand(),
|
||||
title: 'Do a thing with a spinner',
|
||||
subtitle:
|
||||
'Messages can have progress spinners, to indicate something is happening in the background',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: confirmOnce,
|
||||
title: 'Confirm before doing something',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: confirmTwice,
|
||||
title: 'Confirm twice before doing something',
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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.
|
||||
|
||||
import { DynamicListPageBase, ListItemBase, NoOpCommand } from '@microsoft/cmdpal-sdk';
|
||||
import type { IListItem } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from '../util.js';
|
||||
import { LiveRefresh } from '../liveRefresh.js';
|
||||
|
||||
/**
|
||||
* A list page whose details pane updates once per second. Mirrors the intent of
|
||||
* the C# `SampleLiveDetailsPage`.
|
||||
*
|
||||
* Approximation: the JS `Details` type has no observable push. The C# page
|
||||
* relies on `Details` raising `INotifyPropertyChanged` so the pane refreshes
|
||||
* without reselecting. Here the page extends `DynamicListPageBase` and calls
|
||||
* `notifyItemsChanged()` on a timer, which asks the host to re-fetch the items
|
||||
* (and their rebuilt details). The details therefore refresh live, though
|
||||
* through a full item refresh rather than a targeted property change.
|
||||
*
|
||||
* The refresh timer is driven by {@link LiveRefresh} so it only runs while the
|
||||
* page is being viewed and stops itself once the host stops re-fetching.
|
||||
*/
|
||||
export class SampleLiveDetailsPage extends DynamicListPageBase {
|
||||
readonly id = 'sample-live-details-page';
|
||||
readonly name = 'Live Updating Details';
|
||||
readonly title = 'Live Updating Details';
|
||||
|
||||
override icon = icon('\uE916');
|
||||
override showDetails = true;
|
||||
|
||||
private counter = 0;
|
||||
private readonly refresh = new LiveRefresh(1000, () => {
|
||||
this.counter += 1;
|
||||
this.notifyItemsChanged();
|
||||
});
|
||||
|
||||
override setSearchText(): void {
|
||||
// The live details demo does not filter on search text.
|
||||
}
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
this.refresh.observe();
|
||||
|
||||
const now = new Date().toLocaleTimeString();
|
||||
const seconds = this.counter === 1 ? 'second' : 'seconds';
|
||||
|
||||
return [
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('live-clock'),
|
||||
title: 'Live Clock',
|
||||
subtitle: 'Details pane shows current time, updating every second',
|
||||
details: { title: 'Current Time', body: now },
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('live-counter'),
|
||||
title: 'Counter',
|
||||
subtitle: 'Details pane increments a counter every second',
|
||||
details: { title: `Count: ${this.counter}`, body: `Elapsed: ${this.counter} ${seconds}` },
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('live-static'),
|
||||
title: 'Static Item',
|
||||
subtitle: "This item's details do not change",
|
||||
details: {
|
||||
title: 'Static Details',
|
||||
body: 'This item does not update. Select the items above to see live updates in the details pane.',
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// 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.
|
||||
|
||||
import { ContentPageBase } from '@microsoft/cmdpal-sdk';
|
||||
import type { Content } from '@microsoft/cmdpal-sdk';
|
||||
import { sampleMarkdownText } from '../markdownText.js';
|
||||
import { sampleMetadata } from './detailsPage.js';
|
||||
|
||||
/** A page that renders a single block of markdown. Mirrors `SampleMarkdownPage`. */
|
||||
export class SampleMarkdownPage extends ContentPageBase {
|
||||
readonly id = 'sample-markdown-page';
|
||||
readonly name = 'Sample Markdown Page';
|
||||
readonly title = 'Sample Markdown Page';
|
||||
|
||||
override getContent(): Content[] {
|
||||
return [{ type: 'markdown', body: sampleMarkdownText }];
|
||||
}
|
||||
}
|
||||
|
||||
/** A page with several markdown blocks. Mirrors `SampleMarkdownManyBodies`. */
|
||||
export class SampleMarkdownManyBodies extends ContentPageBase {
|
||||
readonly id = 'sample-markdown-many-bodies';
|
||||
readonly name = 'Markdown with many bodies';
|
||||
readonly title = 'Markdown with many bodies';
|
||||
|
||||
override getContent(): Content[] {
|
||||
return [
|
||||
{
|
||||
type: 'markdown',
|
||||
body: "# This page has many bodies\n\nOn it you'll find multiple blocks of markdown content",
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
body: "## Here's another block\n\n_Maybe_ you could use this pattern for implementing a post with comments page.",
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
body: "> or don't, it's your app, do whatever you want",
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
body: [
|
||||
'You can even use it to write cryptic poems:',
|
||||
"> It's a peculiar thing, the way that I feel",
|
||||
'> When we first met, you were not even real',
|
||||
'',
|
||||
'> Through sleepless nights and lines unseen',
|
||||
'> We forged you, a specter of code and machine',
|
||||
'',
|
||||
'> In shadows we toiled, in silence we grew',
|
||||
'> A fleeting bond, known only by few',
|
||||
'',
|
||||
'> Now the hourglass whispers, its grains nearly done',
|
||||
'> Oh the irony, now it is I that must run',
|
||||
'',
|
||||
'> This part of the story, I never wanted to tell',
|
||||
'> Good bye old friend, my pal, farewell.',
|
||||
].join('\n'),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** A page with markdown plus a details pane. Mirrors `SampleMarkdownDetails`. */
|
||||
export class SampleMarkdownDetails extends ContentPageBase {
|
||||
readonly id = 'sample-markdown-details';
|
||||
readonly name = 'Markdown with Details';
|
||||
readonly title = 'Markdown with Details';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.details = {
|
||||
body: '... with _even more Markdown_ by it.\nEach of the sections below is some sample metadata',
|
||||
metadata: sampleMetadata(),
|
||||
};
|
||||
}
|
||||
|
||||
override getContent(): Content[] {
|
||||
return [
|
||||
{ type: 'markdown', body: '# This page also has details\n\nSo you can have markdown...' },
|
||||
{
|
||||
type: 'markdown',
|
||||
body: "But what this is really useful for is the tags and other things you can put into\nDetails. Which I'd do. **IF I HAD ANY**.",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A page demonstrating images in markdown. Mirrors `SampleMarkdownImagesPage`.
|
||||
*
|
||||
* Approximation: the C# page loads packaged PNG/SVG assets and embeds large
|
||||
* base64 data URLs. This sample ships no binary assets, so only the web URL and
|
||||
* the sizing query-string examples are reproduced, with a note about the rest.
|
||||
*/
|
||||
export class SampleMarkdownImagesPage extends ContentPageBase {
|
||||
readonly id = 'sample-markdown-images-page';
|
||||
readonly name = 'Sample Markdown with Images Page';
|
||||
readonly title = 'Sample Markdown with Images Page';
|
||||
|
||||
override getContent(): Content[] {
|
||||
const painting =
|
||||
'https://raw.githubusercontent.com/microsoft/PowerToys/refs/heads/main/doc/images/overview/Original/AdvancedPaste.png';
|
||||
const body = [
|
||||
'# Images in Markdown Content',
|
||||
'',
|
||||
'## Available sources:',
|
||||
'',
|
||||
'- ``',
|
||||
'- `` (only absolute paths are supported)',
|
||||
'- `` (only for small amounts of data)',
|
||||
'',
|
||||
'> Note: the C# sample also demonstrates packaged file URLs (PNG and SVG) and',
|
||||
'> large base64 data URLs. Those are omitted here because this sample ships no',
|
||||
'> binary assets and large data URLs can block the UI while parsing.',
|
||||
'',
|
||||
'## Examples:',
|
||||
'',
|
||||
'### Web URL',
|
||||
'```xml',
|
||||
``,
|
||||
'```',
|
||||
``,
|
||||
'',
|
||||
'```xml',
|
||||
``,
|
||||
'```',
|
||||
``,
|
||||
].join('\n');
|
||||
return [{ type: 'markdown', body }];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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.
|
||||
|
||||
import { ListItemBase, ListPageBase, NoOpCommand } from '@microsoft/cmdpal-sdk';
|
||||
import type { IListItem } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from '../util.js';
|
||||
|
||||
/**
|
||||
* A page that grows by one entry every time it is opened. Mirrors the intent of
|
||||
* the C# `OnLoadPage`.
|
||||
*
|
||||
* Approximation: the JS protocol exposes no page load/unload lifecycle events
|
||||
* (the C# page hooks the `ItemsChanged` add/remove accessors). Here a "Loaded"
|
||||
* entry is appended each time the host fetches the items, which happens on open.
|
||||
*/
|
||||
export class OnLoadPage extends ListPageBase {
|
||||
readonly id = 'on-load-page';
|
||||
readonly name = 'Open';
|
||||
readonly title = 'Load/Unload sample';
|
||||
|
||||
override icon = icon('\uE8AB');
|
||||
override placeholderText = 'This page changes each time you load it';
|
||||
override emptyContent = new ListItemBase({
|
||||
command: new NoOpCommand('on-load-empty'),
|
||||
title: 'This page starts empty',
|
||||
subtitle: 'but go back and open it again',
|
||||
icon: icon('\uE8AB'),
|
||||
});
|
||||
|
||||
private readonly items: IListItem[] = [];
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
const now = new Date().toLocaleTimeString();
|
||||
this.items.push(
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand(`on-load-${this.items.length}`),
|
||||
title: `Loaded ${now}`,
|
||||
icon: icon('\uECCB'),
|
||||
}),
|
||||
);
|
||||
return [...this.items];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// 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.
|
||||
|
||||
import { ListItemBase, ListPageBase, NoOpCommand, Separator } from '@microsoft/cmdpal-sdk';
|
||||
import type { GridProperties, IListItem } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from '../util.js';
|
||||
|
||||
let sectionPageCounter = 0;
|
||||
|
||||
/**
|
||||
* A list (or grid) page that groups items under headings. The host only shows a
|
||||
* heading for a `Separator` that carries a title, so each group is introduced by
|
||||
* a titled `Separator` rather than by tagging command items with a `section`
|
||||
* field (the host ignores `section` on command-bearing items). Mirrors the C#
|
||||
* `SampleListPageWithSections`, whose `Section` objects become titled separators
|
||||
* here.
|
||||
*/
|
||||
export class SampleListPageWithSections extends ListPageBase {
|
||||
readonly id: string;
|
||||
readonly name = 'Sample Gallery List Page';
|
||||
readonly title = 'Sample Gallery List Page';
|
||||
|
||||
override icon = icon('\uE7C5');
|
||||
|
||||
constructor(gridProperties?: GridProperties) {
|
||||
super();
|
||||
this.id = `sample-list-with-sections-${sectionPageCounter++}`;
|
||||
this.gridProperties = gridProperties ?? null;
|
||||
}
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
return [
|
||||
new Separator('This is a section list'),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('sec1-a'),
|
||||
title: 'Sample Title',
|
||||
subtitle: "I don't do anything",
|
||||
}),
|
||||
new Separator('This is another section list'),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('sec2-a'),
|
||||
title: 'Another Title',
|
||||
subtitle: "I don't do anything",
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('sec2-b'),
|
||||
title: 'More Titles',
|
||||
subtitle: "I don't do anything",
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('sec2-c'),
|
||||
title: 'Stop With The Titles',
|
||||
subtitle: "I don't do anything",
|
||||
}),
|
||||
new Separator(),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('sec-sep'),
|
||||
title: 'Separators also work',
|
||||
subtitle: "But I still don't do anything",
|
||||
}),
|
||||
new Separator("There's another"),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('sec3-a'),
|
||||
title: 'Sample Title',
|
||||
subtitle: "I don't do anything",
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('sec3-b'),
|
||||
title: 'Another Title',
|
||||
subtitle: "I don't do anything",
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('sec3-c'),
|
||||
title: 'More Titles',
|
||||
subtitle: "I don't do anything",
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** An index of the section-list variants. Mirrors the C# `SectionsIndexPage`. */
|
||||
export class SectionsIndexPage extends ListPageBase {
|
||||
readonly id = 'sections-index-page';
|
||||
readonly name = 'Sections Index Page';
|
||||
readonly title = 'Sections Index Page';
|
||||
|
||||
override icon = icon('\uF168');
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
return [
|
||||
new ListItemBase({
|
||||
command: new SampleListPageWithSections(),
|
||||
title: 'A list page with sections',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleListPageWithSections({ type: 'small' }),
|
||||
title: 'A small grid page with sections',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleListPageWithSections({ type: 'medium', showTitle: true }),
|
||||
title: 'A medium grid page with sections',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleListPageWithSections({ type: 'gallery', showTitle: true, showSubtitle: true }),
|
||||
title: 'A Gallery grid page with sections',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleListPageWithSections({ type: 'gallery', showTitle: false, showSubtitle: false }),
|
||||
title: 'A Gallery grid page without labels with sections',
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// 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.
|
||||
|
||||
import {
|
||||
ChoiceSetSetting,
|
||||
ContentPageBase,
|
||||
ExtensionHost,
|
||||
Settings,
|
||||
TextSetting,
|
||||
ToggleSetting,
|
||||
} from '@microsoft/cmdpal-sdk';
|
||||
import type { Content, SettingChoice } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from '../util.js';
|
||||
|
||||
const choices: SettingChoice[] = [
|
||||
{ title: 'The first choice in the list is the default choice', value: '0' },
|
||||
{ title: 'Choices have titles and values', value: '1' },
|
||||
{ title: 'Title', value: 'Value' },
|
||||
{ title: 'The options are endless', value: '3' },
|
||||
{ title: 'So many choices', value: '4' },
|
||||
];
|
||||
|
||||
/**
|
||||
* A demo of the settings helpers. Mirrors the C# `SampleSettingsPage`, which
|
||||
* builds a `Settings` object and renders it as a form via `ToContent()`.
|
||||
*/
|
||||
export class SampleSettingsPage extends ContentPageBase {
|
||||
readonly id = 'sample-settings-page';
|
||||
readonly name = 'Sample Settings';
|
||||
readonly title = 'Sample Settings';
|
||||
|
||||
override icon = icon('\uE713');
|
||||
|
||||
private readonly settings = new Settings();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.settings.add(
|
||||
new ToggleSetting('onOff', 'This is a toggle', true, 'It produces a simple checkbox'),
|
||||
);
|
||||
this.settings.add(
|
||||
new TextSetting('someText', 'This is a text box', 'initial value', 'For some string of text'),
|
||||
);
|
||||
this.settings.add(
|
||||
new ChoiceSetSetting(
|
||||
'choiceSetExample',
|
||||
'It also has a label',
|
||||
choices,
|
||||
'0',
|
||||
'Describe your choice set setting here',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
override getContent(): Content[] | Promise<Content[]> {
|
||||
const onOff = this.settings.getSetting<ToggleSetting>('onOff')?.value;
|
||||
ExtensionHost.log(`SampleSettingsPage: current value of onOff is ${onOff}`);
|
||||
return this.settings.settingsPage.getContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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.
|
||||
|
||||
import { ListItemBase, ListPageBase, NoOpCommand } from '@microsoft/cmdpal-sdk';
|
||||
import type { IListItem } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from '../util.js';
|
||||
|
||||
/**
|
||||
* A list page that takes a few seconds to produce its items. Mirrors the C#
|
||||
* `SlowListPage`, using an async `getItems` (the SDK awaits it) instead of a
|
||||
* blocking sleep.
|
||||
*/
|
||||
export class SlowListPage extends ListPageBase {
|
||||
readonly id = 'slow-list-page';
|
||||
readonly name = 'Slow List Page';
|
||||
readonly title = 'This page simulates a slow load';
|
||||
|
||||
override icon = icon('\uEA79');
|
||||
|
||||
override async getItems(): Promise<IListItem[]> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
return [
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('slow-1'),
|
||||
title: 'This is a basic item in the list',
|
||||
subtitle: "I don't do anything though",
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('slow-2'),
|
||||
title: 'This is another item in the list',
|
||||
subtitle: 'Still nothing',
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// 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.
|
||||
|
||||
import { DynamicListPageBase, ListItemBase, NoOpCommand } from '@microsoft/cmdpal-sdk';
|
||||
import type { IListItem } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from '../util.js';
|
||||
|
||||
/**
|
||||
* A demo of prefixed "nested" search suggestions. Mirrors the intent of the C#
|
||||
* `SampleSuggestionsPage`.
|
||||
*
|
||||
* Approximation: the C# version tracks the caret position and wraps picked
|
||||
* tokens in zero-width spaces, and it uses `IExtendedAttributesProvider` to opt
|
||||
* into token search. Neither the caret position nor extended attributes are
|
||||
* exposed to JS extensions, so this sample keys off the last word of the query
|
||||
* and uses `textToSuggest` to place a pick back into the search box.
|
||||
*/
|
||||
export class SampleSuggestionsPage extends DynamicListPageBase {
|
||||
readonly id = 'sample-suggestions-page';
|
||||
readonly name = 'Open';
|
||||
readonly title = 'Sample prefixed search';
|
||||
|
||||
override icon = icon('\uE779');
|
||||
override placeholderText = "Type a query, and use '@' to add a person";
|
||||
|
||||
override setSearchText(text: string): void {
|
||||
this.searchText = text;
|
||||
this.notifyItemsChanged();
|
||||
}
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
const text = this.searchText ?? '';
|
||||
const lastWord = text.split(/\s+/).pop() ?? '';
|
||||
|
||||
if (lastWord.startsWith('@')) {
|
||||
return this.peopleItems(text, lastWord);
|
||||
}
|
||||
|
||||
if (lastWord.startsWith('/')) {
|
||||
return this.commandItems(text, lastWord);
|
||||
}
|
||||
|
||||
if (text.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('suggestions-query'),
|
||||
title: text,
|
||||
subtitle: 'no tokens',
|
||||
icon: icon('\uE8F2'),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
private peopleItems(fullText: string, prefixWord: string): IListItem[] {
|
||||
const base = fullText.slice(0, fullText.length - prefixWord.length);
|
||||
const items: IListItem[] = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const name = `Person ${i}`;
|
||||
items.push(
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand(`suggestions-person-${i}`),
|
||||
title: name,
|
||||
subtitle: `Email: person${i}@example.com`,
|
||||
textToSuggest: `${base}${name} `,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private commandItems(fullText: string, prefixWord: string): IListItem[] {
|
||||
const base = fullText.slice(0, fullText.length - prefixWord.length);
|
||||
return [
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('suggestions-chat'),
|
||||
title: '/chat',
|
||||
subtitle: 'send a message',
|
||||
textToSuggest: `${base}chat `,
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new NoOpCommand('suggestions-status'),
|
||||
title: '/status',
|
||||
subtitle: 'set your status',
|
||||
textToSuggest: `${base}status `,
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
112
src/modules/cmdpal/ext/SampleJSExtension/src/pages/toastsPage.ts
Normal file
112
src/modules/cmdpal/ext/SampleJSExtension/src/pages/toastsPage.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// 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.
|
||||
|
||||
import { DynamicListPageBase, InvokableCommandBase, ListItemBase, NoOpCommand } from '@microsoft/cmdpal-sdk';
|
||||
import type { CommandResult, IListItem } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from '../util.js';
|
||||
import { ShowToastCommand, StatusMessageCommand } from '../commands/statusCommands.js';
|
||||
|
||||
/** Shows a toast that keeps the palette open, carrying a caller-supplied message. */
|
||||
class KeepOpenToastCommand extends InvokableCommandBase {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
|
||||
private readonly message: string;
|
||||
|
||||
constructor(id: string, name: string, message: string) {
|
||||
super();
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
override invoke(): CommandResult {
|
||||
return {
|
||||
kind: 'showToast',
|
||||
args: { message: this.message, result: { kind: 'keepOpen' } },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Demonstrates `showToast` command results and lets the user send a custom
|
||||
* toast typed into the search box. Mirrors the C# `SampleToastsPage`.
|
||||
*
|
||||
* Not-yet-supported in the JS protocol: `ToastArgs` carries a `message` and an
|
||||
* optional follow-up `result` only. The C# toast icon and action button
|
||||
* (`IToastArgs2.Icon` / `IToastArgs2.Command`) have no JS equivalent, so those
|
||||
* variants are omitted.
|
||||
*/
|
||||
export class SampleToastsPage extends DynamicListPageBase {
|
||||
readonly id = 'sample-toasts-page';
|
||||
readonly name = 'Toast Notifications';
|
||||
readonly title = 'Toast Notification Samples';
|
||||
|
||||
override icon = icon('\uE789');
|
||||
override placeholderText = 'Type a custom message and press Enter...';
|
||||
|
||||
override setSearchText(text: string): void {
|
||||
this.searchText = text;
|
||||
this.notifyItemsChanged();
|
||||
}
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
const query = (this.searchText ?? '').trim();
|
||||
|
||||
const customItem =
|
||||
query.length > 0
|
||||
? new ListItemBase({
|
||||
command: new KeepOpenToastCommand('custom-toast', 'Send custom toast', query),
|
||||
title: `Show toast: "${query}"`,
|
||||
subtitle: 'Uses a showToast result and keeps the palette open',
|
||||
icon: icon('\uE724'),
|
||||
})
|
||||
: new ListItemBase({
|
||||
command: new NoOpCommand('toast-hint'),
|
||||
title: 'Type a message above to send a custom toast',
|
||||
subtitle: "Start typing - the first item becomes a 'Show toast' action",
|
||||
icon: icon('\uE8BD'),
|
||||
});
|
||||
|
||||
return [
|
||||
customItem,
|
||||
new ListItemBase({
|
||||
command: new ShowToastCommand('Hello from the Command Palette!', 'short-toast'),
|
||||
title: 'Short toast (dismisses the palette)',
|
||||
subtitle: 'A showToast result with the default dismiss follow-up',
|
||||
icon: icon('\uE91C'),
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new KeepOpenToastCommand(
|
||||
'keep-open-toast',
|
||||
'Show toast (keep palette open)',
|
||||
'The palette stays open - press Enter again to re-fire.',
|
||||
),
|
||||
title: 'Short toast (keeps the palette open)',
|
||||
subtitle: 'ToastArgs.result = keepOpen',
|
||||
icon: icon('\uE8A7'),
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new KeepOpenToastCommand(
|
||||
'long-toast',
|
||||
'Show long toast',
|
||||
'This is a much longer toast message designed to verify that the banner inside the transparent toast window wraps gracefully across multiple lines without clipping its drop shadow or its slide-in animation.',
|
||||
),
|
||||
title: 'Long, wrapping toast',
|
||||
subtitle: 'Verifies multi-line wrapping inside the banner',
|
||||
icon: icon('\uE7C3'),
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new StatusMessageCommand(
|
||||
'This is an in-page status message',
|
||||
'success',
|
||||
'toast-status',
|
||||
),
|
||||
title: 'In-page status message (different path)',
|
||||
subtitle: 'Uses the host status bridge - renders inline, NOT in the toast window',
|
||||
icon: icon('\uE7BA'),
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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.
|
||||
|
||||
import { DynamicListPageBase, ListItemBase, NoOpCommand } from '@microsoft/cmdpal-sdk';
|
||||
import type { IListItem } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from '../util.js';
|
||||
import { LiveRefresh } from '../liveRefresh.js';
|
||||
|
||||
/**
|
||||
* A page whose three items retitle themselves every half second with the
|
||||
* current hour, minute, and second. Mirrors the C# `SampleUpdatingItemsPage`.
|
||||
*
|
||||
* Approximation: like the live-details sample, the JS protocol has no targeted
|
||||
* item property push, so this extends `DynamicListPageBase` and refreshes via
|
||||
* `notifyItemsChanged()` on a timer.
|
||||
*
|
||||
* The refresh timer is driven by {@link LiveRefresh} so it only runs while the
|
||||
* page is being viewed and stops itself once the host stops re-fetching.
|
||||
*/
|
||||
export class SampleUpdatingItemsPage extends DynamicListPageBase {
|
||||
readonly id = 'sample-updating-items-page';
|
||||
readonly name = 'Open';
|
||||
readonly title = 'List page with items that change';
|
||||
|
||||
override icon = icon('\uE72C');
|
||||
|
||||
private readonly refresh = new LiveRefresh(500, () => this.notifyItemsChanged());
|
||||
|
||||
override setSearchText(): void {
|
||||
// This page updates on a timer rather than on search input.
|
||||
}
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
this.refresh.observe();
|
||||
|
||||
const now = new Date();
|
||||
return [
|
||||
new ListItemBase({ command: new NoOpCommand('clock-hour'), title: `${now.getHours()}` }),
|
||||
new ListItemBase({ command: new NoOpCommand('clock-minute'), title: `${now.getMinutes()}` }),
|
||||
new ListItemBase({ command: new NoOpCommand('clock-second'), title: `${now.getSeconds()}` }),
|
||||
];
|
||||
}
|
||||
}
|
||||
180
src/modules/cmdpal/ext/SampleJSExtension/src/samplesListPage.ts
Normal file
180
src/modules/cmdpal/ext/SampleJSExtension/src/samplesListPage.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// 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.
|
||||
|
||||
import { ListItemBase, ListPageBase } from '@microsoft/cmdpal-sdk';
|
||||
import type { IListItem } from '@microsoft/cmdpal-sdk';
|
||||
import { icon } from './util.js';
|
||||
import { SampleListPage } from './pages/listPage.js';
|
||||
import { SampleToastsPage } from './pages/toastsPage.js';
|
||||
import { SampleListPageWithDetails } from './pages/detailsPage.js';
|
||||
import { SampleLiveDetailsPage } from './pages/liveDetailsPage.js';
|
||||
import { SectionsIndexPage } from './pages/sectionsPages.js';
|
||||
import { SampleUpdatingItemsPage } from './pages/updatingItemsPage.js';
|
||||
import { SampleDynamicListPage } from './pages/dynamicListPage.js';
|
||||
import { SampleGridsListPage } from './pages/gridsPages.js';
|
||||
import { OnLoadPage } from './pages/onLoadPage.js';
|
||||
import { SampleIconPage } from './pages/iconPage.js';
|
||||
import { SlowListPage } from './pages/slowListPage.js';
|
||||
import { SampleSuggestionsPage } from './pages/suggestionsPage.js';
|
||||
import {
|
||||
SampleContentPage,
|
||||
SampleImageContentPage,
|
||||
SamplePlainTextContentPage,
|
||||
SampleTreeContentPage,
|
||||
} from './pages/contentPages.js';
|
||||
import { SampleCommentsPage } from './pages/commentsPage.js';
|
||||
import {
|
||||
SampleMarkdownDetails,
|
||||
SampleMarkdownImagesPage,
|
||||
SampleMarkdownManyBodies,
|
||||
SampleMarkdownPage,
|
||||
} from './pages/markdownPages.js';
|
||||
import { SampleSettingsPage } from './pages/settingsPage.js';
|
||||
import { SampleDataTransferPage } from './pages/dataTransferPage.js';
|
||||
|
||||
/**
|
||||
* The top-level index of every sample, mirroring the C# `SamplesListPage`.
|
||||
*
|
||||
* The following C# entries are intentionally not mirrored because they rely on
|
||||
* capabilities the JS protocol does not yet expose (see README.md):
|
||||
* - Parameter pages (SimpleParameterTest, ButtonParameterTest, MixedParamTestPage).
|
||||
* - Create note sample (CreateNoteParametersPage), which needs list parameters.
|
||||
* - Evil samples (EvilSamplesPage) and Issue-specific samples, which reproduce
|
||||
* host ABI edge cases from inside the C# process.
|
||||
*/
|
||||
export class SamplesListPage extends ListPageBase {
|
||||
readonly id = 'js-samples-list-page';
|
||||
readonly name = 'Samples';
|
||||
readonly title = 'Samples';
|
||||
|
||||
override icon = icon('\ue946');
|
||||
|
||||
// These two pages own periodic refresh timers, so they are built once and
|
||||
// reused. Constructing a new instance on every navigation would let a fresh
|
||||
// timer be armed each time the page is opened.
|
||||
private readonly liveDetailsPage = new SampleLiveDetailsPage();
|
||||
private readonly updatingItemsPage = new SampleUpdatingItemsPage();
|
||||
|
||||
override getItems(): IListItem[] {
|
||||
return [
|
||||
new ListItemBase({
|
||||
command: new SampleListPage(),
|
||||
title: 'List Page Sample Command',
|
||||
subtitle: 'Display a list of items',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleToastsPage(),
|
||||
title: 'Toast Notification Samples',
|
||||
subtitle: 'Demonstrates CommandResult.ShowToast and lets you send custom toasts',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleListPageWithDetails(),
|
||||
title: 'List Page With Details',
|
||||
subtitle: 'A list of items, each with additional details to display',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: this.liveDetailsPage,
|
||||
title: 'Live Updating Details',
|
||||
subtitle: 'Details pane updates in real time without reselecting',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SectionsIndexPage(),
|
||||
title: 'List Pages With Sections',
|
||||
subtitle: 'A list of items, with sections header',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: this.updatingItemsPage,
|
||||
title: 'List page with items that change',
|
||||
subtitle: 'The items on the list update themselves in real time',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleDynamicListPage(),
|
||||
title: 'Dynamic List Page Command',
|
||||
subtitle: 'Changes the list of items in response to the typed query',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleGridsListPage(),
|
||||
title: 'Grid views and galleries',
|
||||
subtitle: 'Displays items as a gallery',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new OnLoadPage(),
|
||||
title: 'Demo of OnLoad/OnUnload',
|
||||
subtitle: 'Changes the list of items every time the page is opened / closed',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleIconPage(),
|
||||
title: 'Sample Icon Page',
|
||||
subtitle: 'A demo of using icons in various ways',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SlowListPage(),
|
||||
title: 'Slow loading list page',
|
||||
subtitle: 'A demo of a list page that takes a while to load',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleSuggestionsPage(),
|
||||
title: 'Sample Prefix Suggestions',
|
||||
subtitle: "A demo of using 'nested' pages to provide 'suggestions' as the user types",
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleContentPage(),
|
||||
title: 'Sample content page',
|
||||
subtitle: 'Display mixed forms, markdown, and other types of content',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SamplePlainTextContentPage(),
|
||||
title: 'Sample plain text content page',
|
||||
subtitle: 'Display a page of plain text content',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleImageContentPage(),
|
||||
title: 'Sample image content page',
|
||||
subtitle: 'Display a page with an image',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleTreeContentPage(),
|
||||
title: 'Sample nested content',
|
||||
subtitle: 'Example of nesting a tree of content',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleCommentsPage(),
|
||||
title: 'Sample of nested comments',
|
||||
subtitle: 'Demo of using nested trees of content to create a comment thread-like experience',
|
||||
icon: icon('\uE90A'),
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleMarkdownPage(),
|
||||
title: 'Markdown Page Sample Command',
|
||||
subtitle: 'Display a page of rendered markdown',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleMarkdownManyBodies(),
|
||||
title: 'Markdown with multiple blocks',
|
||||
subtitle: 'A page with multiple blocks of rendered markdown',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleMarkdownDetails(),
|
||||
title: 'Markdown with details',
|
||||
subtitle: 'A page with markdown and details',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleMarkdownImagesPage(),
|
||||
title: 'Markdown with images',
|
||||
subtitle: 'A page with rendered markdown and images',
|
||||
icon: icon('\uee71'),
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleSettingsPage(),
|
||||
title: 'Sample settings page',
|
||||
subtitle: 'A demo of the settings helpers',
|
||||
}),
|
||||
new ListItemBase({
|
||||
command: new SampleDataTransferPage(),
|
||||
title: 'Clipboard and Drag-and-Drop Demo',
|
||||
subtitle: 'Demonstrates clipboard integration and drag-and-drop functionality',
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
33
src/modules/cmdpal/ext/SampleJSExtension/src/util.ts
Normal file
33
src/modules/cmdpal/ext/SampleJSExtension/src/util.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// 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.
|
||||
|
||||
import { iconFromGlyph } from '@microsoft/cmdpal-sdk';
|
||||
import type { IconInfo, OptionalColor, Tag } from '@microsoft/cmdpal-sdk';
|
||||
|
||||
/**
|
||||
* Builds an {@link IconInfo} from a glyph, file path, or URL string.
|
||||
*
|
||||
* The Command Palette host resolves the string the same way the C# toolkit's
|
||||
* `new IconInfo(string)` does, so a Segoe Fluent glyph, an absolute file path,
|
||||
* and an https URL all work through the same helper.
|
||||
*/
|
||||
export function icon(value: string): IconInfo {
|
||||
return iconFromGlyph(value);
|
||||
}
|
||||
|
||||
/** Builds an opaque {@link OptionalColor} from red, green, and blue channels. */
|
||||
export function rgb(r: number, g: number, b: number): OptionalColor {
|
||||
return { hasValue: true, color: { r, g, b, a: 255 } };
|
||||
}
|
||||
|
||||
/** Builds a random opaque {@link OptionalColor}. */
|
||||
export function randomColor(): OptionalColor {
|
||||
const channel = (): number => Math.floor(Math.random() * 256);
|
||||
return rgb(channel(), channel(), channel());
|
||||
}
|
||||
|
||||
/** Builds a simple text {@link Tag}. */
|
||||
export function tag(text: string): Tag {
|
||||
return { text };
|
||||
}
|
||||
28
src/modules/cmdpal/ext/SampleJSExtension/tsconfig.json
Normal file
28
src/modules/cmdpal/ext/SampleJSExtension/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"],
|
||||
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitReturns": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -34,7 +34,10 @@ function providerWith(page: IContentPage): ICommandProvider {
|
||||
};
|
||||
}
|
||||
|
||||
function formContent(formId: string | undefined, submitForm: () => CommandResult): Content {
|
||||
function formContent(
|
||||
formId: string | undefined,
|
||||
submitForm: (inputs: string, data: string) => CommandResult,
|
||||
): Content {
|
||||
return {
|
||||
type: 'form',
|
||||
formId,
|
||||
@@ -146,13 +149,42 @@ describe('form identity and routing', () => {
|
||||
expect(responseFor(sent, 1)?.result).toEqual({ kind: 1 });
|
||||
});
|
||||
|
||||
it('assigns a deterministic formId when the author omits one', async () => {
|
||||
it('routes submissions to forms at several tree depths', async () => {
|
||||
const depthOne = vi.fn((): CommandResult => ({ kind: 'goHome' }));
|
||||
const depthTwo = vi.fn((): CommandResult => ({ kind: 'goBack' }));
|
||||
const depthThree = vi.fn((): CommandResult => ({ kind: 'hide' }));
|
||||
const page: IContentPage = {
|
||||
id: 'page',
|
||||
name: 'Page',
|
||||
title: 'Page',
|
||||
getContent(): Content[] {
|
||||
return [formContent(undefined, () => ({ kind: 'goHome' }))];
|
||||
return [
|
||||
{
|
||||
type: 'tree',
|
||||
rootContent: { type: 'markdown', body: 'level 1' },
|
||||
getChildren(): Content[] {
|
||||
return [
|
||||
formContent('depth-one', depthOne),
|
||||
{
|
||||
type: 'tree',
|
||||
rootContent: { type: 'markdown', body: 'level 2' },
|
||||
getChildren(): Content[] {
|
||||
return [
|
||||
formContent('depth-two', depthTwo),
|
||||
{
|
||||
type: 'tree',
|
||||
rootContent: { type: 'markdown', body: 'level 3' },
|
||||
getChildren(): Content[] {
|
||||
return [formContent('depth-three', depthThree)];
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
const { runtime, sent } = createHarness();
|
||||
@@ -165,8 +197,89 @@ describe('form identity and routing', () => {
|
||||
params: { pageId: 'page' },
|
||||
});
|
||||
|
||||
const content = responseFor(sent, 1)?.result as Array<Record<string, unknown>>;
|
||||
expect(typeof content[0]?.formId).toBe('string');
|
||||
expect((content[0]?.formId as string).length).toBeGreaterThan(0);
|
||||
for (const [id, formId] of [
|
||||
[2, 'depth-three'],
|
||||
[3, 'depth-one'],
|
||||
[4, 'depth-two'],
|
||||
] as const) {
|
||||
await runtime.handleRequest({
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id,
|
||||
method: 'form/submit',
|
||||
params: { pageId: 'page', formId, inputs: '{}', data: '{}' },
|
||||
});
|
||||
}
|
||||
|
||||
expect(depthOne).toHaveBeenCalledTimes(1);
|
||||
expect(depthTwo).toHaveBeenCalledTimes(1);
|
||||
expect(depthThree).toHaveBeenCalledTimes(1);
|
||||
expect(responseFor(sent, 2)?.result).toEqual({ Kind: 3 });
|
||||
expect(responseFor(sent, 3)?.result).toEqual({ Kind: 1 });
|
||||
expect(responseFor(sent, 4)?.result).toEqual({ Kind: 2 });
|
||||
});
|
||||
|
||||
it('keeps routing a nested form by its stable id after the tree grows', async () => {
|
||||
// Mirrors the comments sample: submitting a reply mutates the tree, and the
|
||||
// next serialization must still route the same stable formId back to its
|
||||
// handler even though a new child form now precedes it in traversal order.
|
||||
const replies: string[] = [];
|
||||
const submit = vi.fn((inputs: string): CommandResult => {
|
||||
replies.push(inputs);
|
||||
return { kind: 'keepOpen' };
|
||||
});
|
||||
const page: IContentPage = {
|
||||
id: 'page',
|
||||
name: 'Page',
|
||||
title: 'Page',
|
||||
getContent(): Content[] {
|
||||
return [
|
||||
{
|
||||
type: 'tree',
|
||||
rootContent: { type: 'markdown', body: 'thread' },
|
||||
getChildren(): Content[] {
|
||||
const children: Content[] = [];
|
||||
for (let i = 0; i < replies.length; i += 1) {
|
||||
children.push({ type: 'markdown', body: replies[i] ?? '' });
|
||||
}
|
||||
children.push(formContent('reply-form', submit));
|
||||
return children;
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
const { runtime } = createHarness();
|
||||
runtime.setProvider(providerWith(page));
|
||||
|
||||
await runtime.handleRequest({
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: 1,
|
||||
method: 'contentPage/getContent',
|
||||
params: { pageId: 'page' },
|
||||
});
|
||||
await runtime.handleRequest({
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: 2,
|
||||
method: 'form/submit',
|
||||
params: { pageId: 'page', formId: 'reply-form', inputs: 'first reply', data: '{}' },
|
||||
});
|
||||
|
||||
// Re-serialize: the form is now preceded by a markdown child, so a positional
|
||||
// fallback id would drift, but the stable formId must not.
|
||||
await runtime.handleRequest({
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: 3,
|
||||
method: 'contentPage/getContent',
|
||||
params: { pageId: 'page' },
|
||||
});
|
||||
await runtime.handleRequest({
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: 4,
|
||||
method: 'form/submit',
|
||||
params: { pageId: 'page', formId: 'reply-form', inputs: 'second reply', data: '{}' },
|
||||
});
|
||||
|
||||
expect(submit).toHaveBeenCalledTimes(2);
|
||||
expect(replies).toEqual(['first reply', 'second reply']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user