22// Licensed under the MIT License.
33
44import * as vscode from "vscode" ;
5- import { RequestType0 } from "vscode-languageclient" ;
5+ import { RequestType } from "vscode-languageclient" ;
66import { LanguageClient } from "vscode-languageclient/node" ;
77import { LanguageClientConsumer } from "../languageClientConsumer" ;
8+ import { ShowHelpRequestType } from "./ShowHelp" ;
89
910interface 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 */
2876export 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