diff --git a/docs/Microsoft.PowerShell.ConsoleGuiTools/Show-ObjectTree.md b/docs/Microsoft.PowerShell.ConsoleGuiTools/Show-ObjectTree.md new file mode 100644 index 0000000..d485a63 --- /dev/null +++ b/docs/Microsoft.PowerShell.ConsoleGuiTools/Show-ObjectTree.md @@ -0,0 +1,155 @@ +--- +external help file: ConsoleGuiToolsModule.dll-Help.xml +keywords: powershell,cmdlet +locale: en-us +Module Name: Microsoft.PowerShell.ConsoleGuiTools +ms.date: 07/20/2023 +schema: 2.0.0 +title: Show-ObjectTree +--- + +# Show-ObjectTree + +## SYNOPSIS + +Sends output to an interactive tree in the same console window. + +## SYNTAX + +```PowerShell + Show-ObjectTree [-InputObject ] [-Title ] [-OutputMode {None | Single | + Multiple}] [-Filter ] [-MinUi] [] +``` + +## DESCRIPTION + +The **Show-ObjectTree** cmdlet sends the output from a command to a tree view window where the output is displayed in an interactive tree. + +You can use the following features of the tree to examine your data: + +- Quick Filter. Use the Filter box at the top of the window to search the text in the tree. You can search for literals or multiple words. You can use the `-Filter` command to pre-populate the Filter box. The filter uses regular expressions. + +For instructions for using these features, type `Get-Help Show-ObjectTree -Full` and see How to Use the Tree View Window Features in the Notes section. + +## EXAMPLES + +### Example 1: Output processes to a tree view + +```PowerShell +PS C:\> Get-Process | Show-ObjectTree +``` + +This command gets the processes running on the local computer and sends them to a tree view window. + +### Example 2: Save output to a variable, and then output a tree view + +```PowerShell +PS C:\> ($A = Get-ChildItem -Path $pshome -Recurse) | sot +``` + +This command saves its output in a variable and sends it to **Show-ObjectTree**. + +The command uses the Get-ChildItem cmdlet to get the files in the Windows PowerShell installation directory and its subdirectories. +The path to the installation directory is saved in the $pshome automatic variable. + +The command uses the assignment operator (=) to save the output in the $A variable and the pipeline operator (|) to send the output to **Show-ObjectTree**. + +The parentheses in the command establish the order of operations. +As a result, the output from the Get-ChildItem command is saved in the $A variable before it is sent to **Show-ObjectTree**. + +## PARAMETERS + +### -Filter +Pre-populates the Filter edit box, allowing filtering to be specified on the command line. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -InputObject +Specifies that the cmdlet accepts input for **Show-ObjectTree**. + +When you use the **InputObject** parameter to send a collection of objects to **Show-ObjectTree**, **Show-ObjectTree** treats the collection as one collection object, and it displays one row that represents the collection. + +To display the each object in the collection, use a pipeline operator (|) to send objects to **Show-ObjectTree**. + +```yaml +Type: PSObject +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: True (ByValue) +Accept wildcard characters: False +``` + +### -Title +Specifies the text that appears in the title bar of the **Show-ObjectTree** window. + +By default, the title bar displays the command that invokes **Show-ObjectTree**. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -MinUi +If specified no window frame, filter box, or status bar will be displayed in the **Show-ObjectTree** window. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see about_CommonParameters (http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### System.Management.Automation.PSObject + +You can send any object to this cmdlet. + +## OUTPUTS + +### None + +`Show-ObjectTree` does not output any objects. + +## NOTES + +* The command output that you send to **Show-ObjectTree** should not be formatted, such as by using the Format-Table or Format-Wide cmdlets. To select properties, use the Select-Object cmdlet. + +* Deserialized output from remote commands might not be formatted correctly in the tree view window. + +## RELATED LINKS + +[Out-File](Out-File.md) + +[Out-Printer](Out-Printer.md) + +[Out-String](Out-String.md) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.psd1 b/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.psd1 index 8ba9d30..862359d 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.psd1 +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.psd1 @@ -70,13 +70,13 @@ NestedModules = @() FunctionsToExport = @() # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. -CmdletsToExport = @( 'Out-ConsoleGridView' ) +CmdletsToExport = @( 'Out-ConsoleGridView', 'Show-ObjectTree' ) # Variables to export from this module VariablesToExport = '*' # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. -AliasesToExport = @( 'ocgv' ) +AliasesToExport = @( 'ocgv', 'sot' ) # DSC resources to export from this module # DscResourcesToExport = @() @@ -93,7 +93,7 @@ PrivateData = @{ PSData = @{ # Tags applied to this module. These help with module discovery in online galleries. - Tags = @('Console', 'Gui', 'Out-GridView', 'Out-ConsoleGridView', 'Terminal.Gui', 'gui.cs', 'MacOS', 'Windows', 'Linux', 'PSEdition_Core') + Tags = @('Console', 'Gui', 'Out-GridView', 'Out-ConsoleGridView', 'Show-ObjectTree', 'Terminal.Gui', 'gui.cs', 'MacOS', 'Windows', 'Linux', 'PSEdition_Core') # A URL to the license for this module. LicenseUri = 'https://github.com/PowerShell/GraphicalTools/blob/master/LICENSE.txt' diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs new file mode 100644 index 0000000..1d66c65 --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Management.Automation; +using System.Management.Automation.Internal; +using OutGridView.Models; + +namespace OutGridView.Cmdlet +{ + [Cmdlet("Show", "ObjectTree")] + [Alias("sot")] + public class ShowObjectTreeCmdletCommand : PSCmdlet, IDisposable + { + #region Properties + + private const string DataNotQualifiedForShowObjectTree = nameof(DataNotQualifiedForShowObjectTree); + private const string EnvironmentNotSupportedForShowObjectTree = nameof(EnvironmentNotSupportedForShowObjectTree); + + private List _psObjects = new List(); + + #endregion Properties + + #region Input Parameters + + /// + /// This parameter specifies the current pipeline object. + /// + [Parameter(ValueFromPipeline = true, HelpMessage = "Specifies the input pipeline object")] + public PSObject InputObject { get; set; } = AutomationNull.Value; + + /// + /// Gets/sets the title of the Out-GridView window. + /// + [Parameter(HelpMessage = "Specifies the text that appears in the title bar of the Out-ConsoleGridView window. y default, the title bar displays the command that invokes Out-ConsoleGridView.")] + [ValidateNotNullOrEmpty] + public string Title { get; set; } + + /// + /// gets or sets the initial value for the filter in the GUI + /// + [Parameter(HelpMessage = "Pre-populates the Filter edit box, allowing filtering to be specified on the command line. The filter uses regular expressions." )] + public string Filter { set; get; } + + /// + /// gets or sets the whether "minimum UI" mode will be enabled + /// + [Parameter(HelpMessage = "If specified no window frame, filter box, or status bar will be displayed in the GUI.")] + public SwitchParameter MinUI { set; get; } + /// + /// gets or sets the whether the Terminal.Gui System.Net.Console-based ConsoleDriver will be used instead of the + /// default platform-specific (Windows or Curses) ConsoleDriver. + /// + [Parameter(HelpMessage = "If specified the Terminal.Gui System.Net.Console-based ConsoleDriver (NetDriver) will be used.")] + public SwitchParameter UseNetDriver { set; get; } + + /// + /// For the -Debug switch + /// + public bool Debug => MyInvocation.BoundParameters.TryGetValue("Debug", out var o); + + #endregion Input Parameters + + // This method gets called once for each cmdlet in the pipeline when the pipeline starts executing + protected override void BeginProcessing() + { + if (Console.IsInputRedirected) + { + ErrorRecord error = new ErrorRecord( + new PSNotSupportedException("Not supported in this environment (when input is redirected)."), + EnvironmentNotSupportedForShowObjectTree, + ErrorCategory.NotImplemented, + null); + + ThrowTerminatingError(error); + } + } + + // This method will be called for each input received from the pipeline to this cmdlet; if no input is received, this method is not called + protected override void ProcessRecord() + { + if (InputObject == null || InputObject == AutomationNull.Value) + { + return; + } + + if (InputObject.BaseObject is IDictionary dictionary) + { + // Dictionaries should be enumerated through because the pipeline does not enumerate through them. + foreach (DictionaryEntry entry in dictionary) + { + ProcessObject(PSObject.AsPSObject(entry)); + } + } + else + { + ProcessObject(InputObject); + } + } + + private void ProcessObject(PSObject input) + { + + object baseObject = input.BaseObject; + + // Throw a terminating error for types that are not supported. + if (baseObject is ScriptBlock || + baseObject is SwitchParameter || + baseObject is PSReference || + baseObject is PSObject) + { + ErrorRecord error = new ErrorRecord( + new FormatException("Invalid data type for Show-ObjectTree"), + DataNotQualifiedForShowObjectTree, + ErrorCategory.InvalidType, + null); + + ThrowTerminatingError(error); + } + + _psObjects.Add(input); + } + + // This method will be called once at the end of pipeline execution; if no input is received, this method is not called + protected override void EndProcessing() + { + base.EndProcessing(); + + //Return if no objects + if (_psObjects.Count == 0) + { + return; + } + + var applicationData = new ApplicationData + { + Title = Title ?? "Show-ObjectTree", + Filter = Filter, + MinUI = MinUI, + UseNetDriver = UseNetDriver, + Debug = Debug, + ModuleVersion = MyInvocation.MyCommand.Version.ToString() + }; + + ShowObjectView.Run(_psObjects, applicationData); + } + + public void Dispose() + { + + } + } +} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs new file mode 100644 index 0000000..43a94ee --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs @@ -0,0 +1,444 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Reflection; +using Terminal.Gui; +using Terminal.Gui.Trees; +using System.Management.Automation; +using System.Management.Automation.Internal; +using System.Linq; +using System.Diagnostics; +using System.Collections; +using OutGridView.Models; +using System.Text.RegularExpressions; +using System.IO; + +namespace OutGridView.Cmdlet +{ + internal class ShowObjectView : Window, ITreeBuilder + { + private readonly TreeView tree; + private readonly RegexTreeViewTextFilter filter; + private readonly Label filterErrorLabel; + + public bool SupportsCanExpand => true; + private StatusItem selectedStatusBarItem; + private StatusBar statusBar; + + public ShowObjectView(List rootObjects, ApplicationData applicationData) + { + Title = applicationData.Title; + Width = Dim.Fill(); + Height = Dim.Fill(1); + Modal = false; + + + if(applicationData.MinUI) + { + Border.BorderStyle = BorderStyle.None; + Title = string.Empty; + X = -1; + Height = Dim.Fill(); + } + + tree = new TreeView + { + Y = applicationData.MinUI ? 0 : 2, + Width = Dim.Fill(), + Height = Dim.Fill(), + }; + tree.TreeBuilder = this; + tree.AspectGetter = this.AspectGetter; + tree.SelectionChanged += this.SelectionChanged; + + tree.ClearKeybinding(Command.ExpandAll); + + this.filter = new RegexTreeViewTextFilter(this, tree); + this.filter.Text = applicationData.Filter ?? string.Empty; + tree.Filter = this.filter; + + if (rootObjects.Count > 0) + { + tree.AddObjects(rootObjects); + } + else + { + tree.AddObject("No Objects"); + } + statusBar = new StatusBar(); + + string elementDescription = "objects"; + + var types = rootObjects.Select(o=>o.GetType()).Distinct().ToArray(); + if(types.Length == 1) + { + elementDescription = types[0].Name; + } + + var lblFilter = new Label(){ + Text = "Filter:", + X = 1, + }; + var tbFilter = new TextField(){ + X = Pos.Right(lblFilter), + Width = Dim.Fill(1), + Text = applicationData.Filter ?? string.Empty + }; + tbFilter.CursorPosition = tbFilter.Text.Length; + + tbFilter.TextChanged += (_)=>{ + filter.Text = tbFilter.Text.ToString(); + }; + + + filterErrorLabel = new Label(string.Empty) + { + X = Pos.Right(lblFilter) + 1, + Y = Pos.Top(lblFilter) + 1, + ColorScheme = Colors.Base, + Width = Dim.Fill() - lblFilter.Text.Length + }; + + if(!applicationData.MinUI) + { + Add(lblFilter); + Add(tbFilter); + Add(filterErrorLabel); + } + + int pos = 0; + statusBar.AddItemAt(pos++, new StatusItem(Key.Esc, "~ESC~ Close", () => Application.RequestStop())); + + var siCount = new StatusItem(Key.Null, $"{rootObjects.Count} {elementDescription}",null); + selectedStatusBarItem = new StatusItem(Key.Null, string.Empty,null); + statusBar.AddItemAt(pos++,siCount); + statusBar.AddItemAt(pos++,selectedStatusBarItem); + + if ( applicationData.Debug) + { + statusBar.AddItemAt(pos++,new StatusItem(Key.Null, $" v{applicationData.ModuleVersion}", null)); + statusBar.AddItemAt(pos++,new StatusItem(Key.Null, + $"{Application.Driver} v{FileVersionInfo.GetVersionInfo(Assembly.GetAssembly(typeof(Application)).Location).ProductVersion}", null)); + } + + statusBar.Visible = !applicationData.MinUI; + Application.Top.Add(statusBar); + + Add(tree); + } + private void SetRegexError(string error) + { + if(string.Equals(error, filterErrorLabel.Text.ToString())) + { + return; + } + filterErrorLabel.Text = error; + filterErrorLabel.ColorScheme = Colors.Error; + filterErrorLabel.Redraw(filterErrorLabel.Bounds); + } + + private void SelectionChanged(object sender, SelectionChangedEventArgs e) + { + var selectedValue = e.NewValue; + + if( selectedValue is CachedMemberResult cmr) + { + selectedValue = cmr.Value; + } + + if(selectedValue != null && selectedStatusBarItem != null) + { + selectedStatusBarItem.Title = selectedValue.GetType().Name; + } + else + { + selectedStatusBarItem.Title = string.Empty; + } + + statusBar.SetNeedsDisplay(); + } + + private string AspectGetter(object toRender) + { + if(toRender is Process p) + { + return p.ProcessName; + } + if(toRender is null) + { + return "Null"; + } + if(toRender is FileSystemInfo fsi && !IsRootObject(fsi)) + { + return fsi.Name; + } + + return toRender.ToString(); + } + + private bool IsRootObject(object o) + { + return tree.Objects.Contains(o); + } + + public bool CanExpand(object toExpand) + { + if (toExpand is CachedMemberResult p) + { + return IsBasicType(p?.Value); + } + + // Any complex object type can be expanded to reveal properties + return IsBasicType(toExpand); + } + + private bool IsBasicType(object value) + { + return value != null && value is not string && !value.GetType().IsValueType; + } + + public IEnumerable GetChildren(object forObject) + { + if(forObject is CachedMemberResult p) + { + if(p.IsCollection) + { + return p.Elements; + } + + return GetChildren(p.Value); + } + + if(forObject is CachedMemberResultElement e) + { + return GetChildren(e.Value); + } + + List children = new List(); + + foreach(var member in forObject.GetType().GetMembers().OrderBy(m=>m.Name)) + { + if(member is PropertyInfo prop) + { + children.Add(new CachedMemberResult(forObject, prop)); + } + if(member is FieldInfo field) + { + children.Add(new CachedMemberResult(forObject, field)); + } + } + + try{ + children.AddRange(GetExtraChildren(forObject)); + } + catch(Exception) + { + // Extra children unavailable, possibly security or IO exceptions enumerating children etc + } + + return children; + } + + private IEnumerable GetExtraChildren(object forObject) + { + if(forObject is DirectoryInfo dir) + { + foreach(var c in dir.EnumerateFileSystemInfos()) + { + yield return c; + } + } + } + + internal static void Run(List objects, ApplicationData applicationData) + { + // Note, in Terminal.Gui v2, this property is renamed to Application.UseNetDriver, hence + // using that terminology here. + Application.UseSystemConsole = applicationData.UseNetDriver; + Application.Init(); + Window window = null; + + try + { + window = new ShowObjectView(objects.Select(p=>p.BaseObject).ToList(), applicationData); + Application.Top.Add(window); + Application.Run(); + } + finally{ + Application.Shutdown(); + window?.Dispose(); + } + } + + class CachedMemberResultElement + { + public int Index; + public object Value; + + private string representation; + + public CachedMemberResultElement(object value, int index) + { + Index = index; + Value = value; + + try{ + representation = Value?.ToString() ?? "Null"; + } + catch (Exception) + { + Value = representation = "Unavailable"; + } + } + public override string ToString() + { + return $"[{Index}]: {representation}]"; + } + } + + class CachedMemberResult + { + public MemberInfo Member; + public object Value; + public object Parent; + private string representation; + private List valueAsList; + + + public bool IsCollection => valueAsList != null; + public IReadOnlyCollection Elements => valueAsList?.AsReadOnly(); + + public CachedMemberResult(object parent, MemberInfo mem) + { + Parent = parent; + Member = mem; + + try + { + if (mem is PropertyInfo p) + { + Value = p.GetValue(parent); + } + else if (mem is FieldInfo f) + { + Value = f.GetValue(parent); + } + else + { + throw new NotSupportedException($"Unknown {nameof(MemberInfo)} Type"); + } + + representation = ValueToString(); + + } + catch (Exception) + { + Value = representation = "Unavailable"; + } + } + + private string ValueToString() + { + if(Value == null) + { + return "Null"; + } + try{ + if(IsCollectionOfKnownTypeAndSize(out Type elementType, out int size)) + { + return $"{elementType.Name}[{size}]"; + } + }catch(Exception) + { + return Value?.ToString(); + } + + + return Value?.ToString(); + } + + private bool IsCollectionOfKnownTypeAndSize(out Type elementType, out int size) + { + elementType = null; + size = 0; + + if(Value == null || Value is string) + { + + return false; + } + + if(Value is IEnumerable ienumerable) + { + var list = ienumerable.Cast().ToList(); + + var types = list.Where(v=>v!=null).Select(v=>v.GetType()).Distinct().ToArray(); + + if(types.Length == 1) + { + elementType = types[0]; + size = list.Count; + + valueAsList = list.Select((e,i)=>new CachedMemberResultElement(e,i)).ToList(); + return true; + } + } + + return false; + } + + public override string ToString() + { + return Member.Name + ": " + representation; + } + } + private class RegexTreeViewTextFilter : ITreeViewFilter + { + private readonly ShowObjectView parent; + readonly TreeView _forTree; + + public RegexTreeViewTextFilter (ShowObjectView parent, TreeView forTree) + { + this.parent = parent; + _forTree = forTree ?? throw new ArgumentNullException (nameof (forTree)); + } + + private string text; + + public string Text { + get { return text; } + set { + text = value; + RefreshTreeView (); + } + } + + private void RefreshTreeView () + { + _forTree.InvalidateLineMap (); + _forTree.SetNeedsDisplay (); + } + + public bool IsMatch (object model) + { + if (string.IsNullOrWhiteSpace (Text)) { + return true; + } + + parent.SetRegexError(string.Empty); + + var modelText = _forTree.AspectGetter (model); + try{ + return Regex.IsMatch(modelText, text, RegexOptions.IgnoreCase); + } + catch(RegexParseException e) + { + parent.SetRegexError(e.Message); + return true; + } + } + } + } +}