Skip to content

Commit d655273

Browse files
Redesign the Command Explorer around modules and lazy loading
The Command Explorer fetched the full command table up front (names, modules, and parameter metadata for everything), which took minutes to populate and showed a flat, hard-to-scan list. Rework it into a lazy, module-grouped tree: - Top-level nodes are modules (with version), expanded on demand; their commands are fetched per module with `excludeParameters` so only names and modules cross the wire. - Module-less commands are collected under a neutral "Functions & Scripts" node with a folder icon, and the request sets `excludeDefaultFunctions` so PowerShell's default-session plumbing doesn't clutter it. - Hovering a module shows its metadata via the new `powerShell/getModule` request, cached so repeated hovers don't re-fetch. - The inline Show Help / Insert command actions are gated to `viewItem == command` so they no longer appear on module nodes. Default the Command Explorer to visible. Because its default now equals the value the ISE profile sets, the ISE compatibility tests could no longer observe a revert by inequality; guard those assertions with `revertIsObservable`, which skips settings whose default already matches the ISE value. Drafted by Copilot (Claude Opus 4.8). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0b48dd2 commit d655273

3 files changed

Lines changed: 275 additions & 56 deletions

File tree

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -391,12 +391,12 @@
391391
"view/item/context": [
392392
{
393393
"command": "PowerShell.ShowHelp",
394-
"when": "view == PowerShellCommands",
394+
"when": "view == PowerShellCommands && viewItem == command",
395395
"group": "inline@1"
396396
},
397397
{
398398
"command": "PowerShell.InsertCommand",
399-
"when": "view == PowerShellCommands",
399+
"when": "view == PowerShellCommands && viewItem == command",
400400
"group": "inline@2"
401401
}
402402
]
@@ -748,7 +748,7 @@
748748
},
749749
"powershell.sideBar.CommandExplorerVisibility": {
750750
"type": "boolean",
751-
"default": false,
751+
"default": true,
752752
"markdownDescription": "Specifies the visibility of the Command Explorer in the side bar."
753753
},
754754
"powershell.sideBar.CommandExplorerExcludeFilter": {

src/features/GetCommands.ts

Lines changed: 247 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,81 @@
22
// Licensed under the MIT License.
33

44
import * as vscode from "vscode";
5-
import { RequestType0 } from "vscode-languageclient";
5+
import { RequestType } from "vscode-languageclient";
66
import { LanguageClient } from "vscode-languageclient/node";
77
import { LanguageClientConsumer } from "../languageClientConsumer";
8+
import { ShowHelpRequestType } from "./ShowHelp";
89

910
interface ICommand {
1011
name: string;
1112
moduleName: string;
12-
defaultParameterSet: string;
13-
parameterSets: object;
14-
parameters: object;
13+
moduleVersion?: string;
14+
// Parameter metadata is omitted when the request sets excludeParameters
15+
// (e.g. the Command Explorer tree, which only needs names and modules).
16+
defaultParameterSet?: string;
17+
parameterSets?: object;
18+
parameters?: Record<string, object>;
19+
}
20+
21+
export interface IGetCommandArguments {
22+
name?: string;
23+
module?: string;
24+
excludeParameters?: boolean;
25+
excludeDefaultFunctions?: boolean;
1526
}
1627

1728
/**
1829
* RequestType sent over to PSES.
19-
* Expects: ICommand to be returned
30+
* Optionally scoped by command name and/or module (both support wildcards);
31+
* when neither is provided, all commands are returned. Set excludeParameters to
32+
* omit the expensive parameter metadata when only names/modules are needed, and
33+
* excludeDefaultFunctions to drop PowerShell's default-session shell functions
34+
* (e.g. cd.., prompt, TabExpansion2) that aren't meaningful in the command list.
35+
* Expects: ICommand[] to be returned
2036
*/
21-
export const GetCommandRequestType = new RequestType0<ICommand[], void>(
22-
"powerShell/getCommand",
23-
);
37+
export const GetCommandRequestType = new RequestType<
38+
IGetCommandArguments,
39+
ICommand[],
40+
void
41+
>("powerShell/getCommand");
42+
43+
interface IGetModuleArguments {
44+
name: string;
45+
version?: string;
46+
}
47+
48+
interface IModule {
49+
name: string;
50+
version: string;
51+
description: string;
52+
path: string;
53+
author: string;
54+
companyName: string;
55+
projectUri: string;
56+
powerShellVersion: string;
57+
}
58+
59+
/**
60+
* RequestType sent over to PSES to retrieve a single module's metadata (used to
61+
* populate the Command Explorer's module tooltips on hover).
62+
*/
63+
export const GetModuleRequestType = new RequestType<
64+
IGetModuleArguments,
65+
IModule | null,
66+
void
67+
>("powerShell/getModule");
68+
69+
type CommandExplorerNode = ModuleNode | CommandNode;
2470

2571
/**
26-
* A PowerShell Command listing feature. Implements a treeview control.
72+
* A PowerShell Command listing feature. Implements a treeview control that
73+
* groups commands by module, loading only command names and modules (parameter
74+
* metadata is expensive to serialize and isn't shown in the tree).
2775
*/
2876
export class GetCommandsFeature extends LanguageClientConsumer {
2977
private commands: vscode.Disposable[];
3078
private commandsExplorerProvider: CommandsExplorerProvider;
31-
private commandsExplorerTreeView: vscode.TreeView<Command>;
79+
private commandsExplorerTreeView: vscode.TreeView<CommandExplorerNode>;
3280

3381
constructor() {
3482
super();
@@ -48,10 +96,11 @@ export class GetCommandsFeature extends LanguageClientConsumer {
4896
];
4997
this.commandsExplorerProvider = new CommandsExplorerProvider();
5098

51-
this.commandsExplorerTreeView = vscode.window.createTreeView<Command>(
52-
"PowerShellCommands",
53-
{ treeDataProvider: this.commandsExplorerProvider },
54-
);
99+
this.commandsExplorerTreeView =
100+
vscode.window.createTreeView<CommandExplorerNode>(
101+
"PowerShellCommands",
102+
{ treeDataProvider: this.commandsExplorerProvider },
103+
);
55104

56105
// Refresh the command explorer when the view is visible
57106
this.commandsExplorerTreeView.onDidChangeVisibility(async (e) => {
@@ -77,7 +126,10 @@ export class GetCommandsFeature extends LanguageClientConsumer {
77126

78127
private async CommandExplorerRefresh(): Promise<void> {
79128
const client = await LanguageClientConsumer.getLanguageClient();
80-
const result = await client.sendRequest(GetCommandRequestType);
129+
const result = await client.sendRequest(GetCommandRequestType, {
130+
excludeParameters: true,
131+
excludeDefaultFunctions: true,
132+
});
81133
const exclusions = vscode.workspace
82134
.getConfiguration("powershell.sideBar")
83135
.get<string[]>("CommandExplorerExcludeFilter", []);
@@ -88,9 +140,7 @@ export class GetCommandsFeature extends LanguageClientConsumer {
88140
(command) =>
89141
!excludeFilter.includes(command.moduleName.toLowerCase()),
90142
);
91-
this.commandsExplorerProvider.powerShellCommands =
92-
filteredResult.map(toCommand);
93-
this.commandsExplorerProvider.refresh();
143+
this.commandsExplorerProvider.setCommands(filteredResult);
94144
}
95145

96146
private async InsertCommand(item: { Name: string }): Promise<void> {
@@ -113,62 +163,206 @@ export class GetCommandsFeature extends LanguageClientConsumer {
113163
}
114164
}
115165

116-
class CommandsExplorerProvider implements vscode.TreeDataProvider<Command> {
117-
public readonly onDidChangeTreeData: vscode.Event<Command | undefined>;
118-
public powerShellCommands: Command[] = [];
119-
private didChangeTreeData: vscode.EventEmitter<Command | undefined> =
120-
new vscode.EventEmitter<Command>();
166+
class CommandsExplorerProvider implements vscode.TreeDataProvider<CommandExplorerNode> {
167+
public readonly onDidChangeTreeData: vscode.Event<
168+
CommandExplorerNode | undefined
169+
>;
170+
private modules: ModuleNode[] = [];
171+
// Tooltips are cached by key (command name / module+version) so repeat hovers
172+
// within a single tree don't re-issue the (slow) request. The caches are
173+
// cleared on each refresh (see setCommands) since help may have changed.
174+
private readonly commandTooltips = new Map<string, vscode.MarkdownString>();
175+
private readonly moduleTooltips = new Map<string, vscode.MarkdownString>();
176+
private didChangeTreeData: vscode.EventEmitter<
177+
CommandExplorerNode | undefined
178+
> = new vscode.EventEmitter<CommandExplorerNode | undefined>();
121179

122180
constructor() {
123181
this.onDidChangeTreeData = this.didChangeTreeData.event;
124182
}
125183

126-
public refresh(): void {
184+
// Groups the flat command list into module -> command nodes. Commands are
185+
// keyed by module name AND version, so a module installed in multiple versions
186+
// (e.g. Pester 4 and 5) becomes separate rows rather than showing duplicate
187+
// command names.
188+
public setCommands(commands: ICommand[]): void {
189+
// A refresh may reflect newly imported modules or updated help, so drop
190+
// the cached tooltips and let them be re-fetched lazily on next hover.
191+
this.commandTooltips.clear();
192+
this.moduleTooltips.clear();
193+
194+
const byModule = new Map<
195+
string,
196+
{ moduleName: string; version: string; nodes: CommandNode[] }
197+
>();
198+
for (const command of commands) {
199+
const moduleName = command.moduleName || "";
200+
const version = command.moduleVersion ?? "";
201+
const key = `${moduleName}\u0000${version}`;
202+
const group = byModule.get(key) ?? {
203+
moduleName,
204+
version,
205+
nodes: [],
206+
};
207+
group.nodes.push(new CommandNode(command.name, moduleName));
208+
byModule.set(key, group);
209+
}
210+
211+
this.modules = [...byModule.values()]
212+
.map(
213+
({ moduleName, version, nodes }) =>
214+
new ModuleNode(
215+
moduleName,
216+
version,
217+
nodes.sort((a, b) => a.Name.localeCompare(b.Name)),
218+
),
219+
)
220+
.sort(
221+
(a, b) =>
222+
// Group a module's versions together, newest first.
223+
a.ModuleName.localeCompare(b.ModuleName) ||
224+
b.Version.localeCompare(a.Version, undefined, {
225+
numeric: true,
226+
}),
227+
);
228+
127229
this.didChangeTreeData.fire(undefined);
128230
}
129231

130-
public getTreeItem(element: Command): vscode.TreeItem {
232+
public getTreeItem(element: CommandExplorerNode): vscode.TreeItem {
131233
return element;
132234
}
133235

134-
public getChildren(_element?: Command): Thenable<Command[]> {
135-
return Promise.resolve(this.powerShellCommands);
236+
// Lazily populates a node's tooltip the first time the user hovers it. Command
237+
// nodes show their help (reusing the powerShell/showHelp request); module nodes
238+
// show their metadata (powerShell/getModule). Results are cached by key so the
239+
// (slow) request only runs once per command/module, even across tree rebuilds.
240+
public async resolveTreeItem(
241+
item: vscode.TreeItem,
242+
element: CommandExplorerNode,
243+
token: vscode.CancellationToken,
244+
): Promise<vscode.TreeItem> {
245+
if (item.tooltip !== undefined) {
246+
return item;
247+
}
248+
249+
if (element instanceof CommandNode) {
250+
const cached = this.commandTooltips.get(element.Name);
251+
if (cached !== undefined) {
252+
item.tooltip = cached;
253+
return item;
254+
}
255+
256+
const client = await LanguageClientConsumer.getLanguageClient();
257+
const result = await client.sendRequest(
258+
ShowHelpRequestType,
259+
{ text: element.Name },
260+
token,
261+
);
262+
if (result.helpText) {
263+
const tooltip = new vscode.MarkdownString();
264+
tooltip.appendCodeblock(result.helpText, "powershell");
265+
this.commandTooltips.set(element.Name, tooltip);
266+
item.tooltip = tooltip;
267+
}
268+
return item;
269+
}
270+
271+
if (element instanceof ModuleNode && element.ModuleName) {
272+
const key = `${element.ModuleName}\u0000${element.Version}`;
273+
const cached = this.moduleTooltips.get(key);
274+
if (cached !== undefined) {
275+
item.tooltip = cached;
276+
return item;
277+
}
278+
279+
const client = await LanguageClientConsumer.getLanguageClient();
280+
const module = await client.sendRequest(
281+
GetModuleRequestType,
282+
{ name: element.ModuleName, version: element.Version },
283+
token,
284+
);
285+
if (module) {
286+
const tooltip = ModuleNode.buildTooltip(
287+
module,
288+
element.commands.length,
289+
);
290+
this.moduleTooltips.set(key, tooltip);
291+
item.tooltip = tooltip;
292+
}
293+
return item;
294+
}
295+
296+
return item;
136297
}
137-
}
138298

139-
function toCommand(command: ICommand): Command {
140-
return new Command(
141-
command.name,
142-
command.moduleName,
143-
command.defaultParameterSet,
144-
command.parameterSets,
145-
command.parameters,
146-
);
299+
public getChildren(
300+
element?: CommandExplorerNode,
301+
): Thenable<CommandExplorerNode[]> {
302+
if (element === undefined) {
303+
return Promise.resolve(this.modules);
304+
}
305+
if (element instanceof ModuleNode) {
306+
return Promise.resolve(element.commands);
307+
}
308+
return Promise.resolve([]);
309+
}
147310
}
148311

149-
class Command extends vscode.TreeItem {
312+
class ModuleNode extends vscode.TreeItem {
150313
constructor(
151-
public readonly Name: string,
152314
public readonly ModuleName: string,
153-
public readonly defaultParameterSet: string,
154-
public readonly ParameterSets: object,
155-
public readonly Parameters: object,
156-
public override readonly collapsibleState = vscode
157-
.TreeItemCollapsibleState.None,
315+
public readonly Version: string,
316+
public readonly commands: CommandNode[],
158317
) {
159-
super(Name, collapsibleState);
318+
super(
319+
// Commands not exported by a module (built-in and profile-defined
320+
// functions, and scripts on the PATH) are grouped under a friendly label.
321+
ModuleName || "Functions & Scripts",
322+
vscode.TreeItemCollapsibleState.Collapsed,
323+
);
324+
this.contextValue = "module";
325+
// Real modules get the "library" icon; the catch-all bucket gets a neutral
326+
// grouping icon so it doesn't read as an actual module.
327+
this.iconPath = new vscode.ThemeIcon(ModuleName ? "library" : "folder");
328+
// Show the version next to the module name, which also disambiguates a
329+
// module installed in more than one version.
330+
if (Version) {
331+
this.description = Version;
332+
}
160333
}
161334

162-
public getTreeItem(): vscode.TreeItem {
163-
return {
164-
label: this.label,
165-
collapsibleState: this.collapsibleState,
166-
};
335+
// Builds a rich Markdown tooltip from a module's metadata.
336+
public static buildTooltip(
337+
module: IModule,
338+
commandCount: number,
339+
): vscode.MarkdownString {
340+
const tooltip = new vscode.MarkdownString();
341+
tooltip.appendMarkdown(`**${module.name}** ${module.version}\n\n`);
342+
if (module.description) {
343+
tooltip.appendMarkdown(`${module.description}\n\n`);
344+
}
345+
tooltip.appendMarkdown(`_${commandCount} commands_\n\n`);
346+
if (module.author) {
347+
tooltip.appendMarkdown(`Author: ${module.author}\n\n`);
348+
}
349+
if (module.projectUri) {
350+
tooltip.appendMarkdown(`[Project](${module.projectUri})\n\n`);
351+
}
352+
if (module.path) {
353+
tooltip.appendMarkdown(`\`${module.path}\``);
354+
}
355+
return tooltip;
167356
}
357+
}
168358

169-
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/require-await
170-
public async getChildren(_element?: any): Promise<Command[]> {
171-
// Returning an empty array because we need to return something.
172-
return [];
359+
class CommandNode extends vscode.TreeItem {
360+
constructor(
361+
public readonly Name: string,
362+
public readonly ModuleName: string,
363+
) {
364+
super(Name, vscode.TreeItemCollapsibleState.None);
365+
this.contextValue = "command";
366+
this.iconPath = new vscode.ThemeIcon("symbol-method");
173367
}
174368
}

0 commit comments

Comments
 (0)