diff --git a/.gitignore b/.gitignore index 80046ed..0537311 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,49 @@ -module/ -out/ -bin/ -obj/ +*.swp +*.*~ +project.lock.json +.DS_Store +*.pyc +nupkg/ + +# Rider +.idea + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +msbuild.log +msbuild.err +msbuild.wrn + publish/ -*.sln + +#Module build +module/ + +# Visual Studio 2015 +.vs/ + +# ingore downloaded .NET +.dotnet + +# Ignore package +Microsoft.PowerShell.GraphicalTools.zip +Microsoft.PowerShell.ConsoleGuiTools.zip + +# git artifacts +*.orig \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3b66410 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "git.ignoreLimitWarning": true +} \ No newline at end of file diff --git a/ConsoleGuiTools.build.ps1 b/ConsoleGuiTools.build.ps1 index 3fa225b..5ba0fa6 100644 --- a/ConsoleGuiTools.build.ps1 +++ b/ConsoleGuiTools.build.ps1 @@ -23,15 +23,18 @@ task Build { Push-Location src/Microsoft.PowerShell.ConsoleGuiTools Invoke-BuildExec { & dotnet publish --configuration $Configuration --output publish } - $Assets = $( - "./publish/Microsoft.PowerShell.ConsoleGuiTools.dll", - "./publish/Microsoft.PowerShell.ConsoleGuiTools.psd1", - "./publish/Microsoft.PowerShell.OutGridView.Models.dll", - "./publish/Terminal.Gui.dll", - "./publish/NStack.dll") - $Assets | ForEach-Object { - Copy-Item -Force -Path $_ -Destination ../../module + + # Copy all DLLs except PowerShell SDK dependencies (those are provided by PowerShell itself) + Get-ChildItem "./publish/*.dll" | Where-Object { + $_.Name -notlike "System.Management.Automation.dll" -and + $_.Name -notlike "Microsoft.PowerShell.Commands.Diagnostics.dll" -and + $_.Name -notlike "Microsoft.Management.Infrastructure.CimCmdlets.dll" + } | ForEach-Object { + Copy-Item -Force -Path $_.FullName -Destination ../../module } + + # Copy the module manifest + Copy-Item -Force -Path "./publish/Microsoft.PowerShell.ConsoleGuiTools.psd1" -Destination ../../module Pop-Location $Assets = $( diff --git a/Directory.Packages.props b/Directory.Packages.props index be0590b..fc10969 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,7 @@ - - + + - + \ No newline at end of file diff --git a/GraphicalTools.sln b/GraphicalTools.sln new file mode 100644 index 0000000..0789aa8 --- /dev/null +++ b/GraphicalTools.sln @@ -0,0 +1,36 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11206.111 d18.3 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.PowerShell.ConsoleGuiTools", "src\Microsoft.PowerShell.ConsoleGuiTools\Microsoft.PowerShell.ConsoleGuiTools.csproj", "{C0749375-3F76-9F36-9A4D-6857B5504C9A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.PowerShell.OutGridView.Models", "src\Microsoft.PowerShell.OutGridView.Models\Microsoft.PowerShell.OutGridView.Models.csproj", "{D5EDF10B-A646-FC2C-FF3C-B7F2C1814863}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C0749375-3F76-9F36-9A4D-6857B5504C9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0749375-3F76-9F36-9A4D-6857B5504C9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0749375-3F76-9F36-9A4D-6857B5504C9A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0749375-3F76-9F36-9A4D-6857B5504C9A}.Release|Any CPU.Build.0 = Release|Any CPU + {D5EDF10B-A646-FC2C-FF3C-B7F2C1814863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5EDF10B-A646-FC2C-FF3C-B7F2C1814863}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5EDF10B-A646-FC2C-FF3C-B7F2C1814863}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5EDF10B-A646-FC2C-FF3C-B7F2C1814863}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C0749375-3F76-9F36-9A4D-6857B5504C9A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {D5EDF10B-A646-FC2C-FF3C-B7F2C1814863} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7EA28E35-5572-47D2-B03E-5DB79D82BEBC} + EndGlobalSection +EndGlobal diff --git a/GraphicalTools.sln.DotSettings b/GraphicalTools.sln.DotSettings new file mode 100644 index 0000000..40a04f5 --- /dev/null +++ b/GraphicalTools.sln.DotSettings @@ -0,0 +1,7 @@ + + PS + UI + <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static readonly fields (private)"><ElementKinds><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="AA_BB" /></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Private" Description="Constant fields (private)"><ElementKinds><Kind Name="CONSTANT_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="AA_BB" /></Policy> + True + True \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/CachedMemberResult.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/CachedMemberResult.cs new file mode 100644 index 0000000..14534c5 --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/CachedMemberResult.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Represents a cached reflection result for a property or field member, including its value and collection details. +/// +internal sealed class CachedMemberResult +{ + #region Fields + + private readonly string? _representation; + private List? _valueAsList; + + #endregion + + #region Properties + + /// + /// Gets or sets the member information (property or field) that was accessed. + /// + public MemberInfo Member { get; set; } + + /// + /// Gets or sets the value retrieved from the member. + /// + public object? Value { get; set; } + + /// + /// Gets or sets the parent object that contains this member. + /// + public object Parent { get; set; } + + /// + /// Gets a value indicating whether this member's value is a collection. + /// + public bool IsCollection => _valueAsList != null; + + /// + /// Gets the collection elements if this member's value is a collection; otherwise, . + /// + public IReadOnlyCollection? Elements => _valueAsList?.AsReadOnly(); + + #endregion + + #region Constructor + + /// + /// Initializes a new instance of the class by reflecting on the specified member. + /// + /// The parent object containing the member. + /// The member information to retrieve the value from. + 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"; + } + } + + #endregion + + #region Overrides + + /// + /// Returns a string representation of this member in the format "MemberName: value". + /// + /// A formatted string showing the member name and value. + public override string ToString() => Member.Name + ": " + _representation; + + #endregion + + #region Private Methods + + /// + /// Converts the member's value to a string representation, detecting collections and formatting them appropriately. + /// + /// A string representation of the value. + private string? ValueToString() + { + if (Value == null) + return "Null"; + + try + { + if (IsCollectionOfKnownTypeAndSize(out var elementType, out var size)) + return $"{elementType!.Name}[{size}]"; + } + catch (Exception) + { + return Value?.ToString(); + } + + return Value?.ToString(); + } + + /// + /// Determines whether the value is a collection of a known type and caches the collection elements. + /// + /// When this method returns, contains the element type if the value is a homogeneous collection; otherwise, . + /// When this method returns, contains the size of the collection if applicable; otherwise, 0. + /// if the value is a collection of a single known type; otherwise, . + private bool IsCollectionOfKnownTypeAndSize(out Type? elementType, out int size) + { + elementType = null; + size = 0; + + if (Value is null or string) + return false; + + if (Value is IEnumerable enumerable) + { + var list = enumerable.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; + } + + #endregion +} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/CachedMemberResultElement.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/CachedMemberResultElement.cs new file mode 100644 index 0000000..7efa001 --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/CachedMemberResultElement.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Represents an element within a collection member result, providing indexed access to collection items. +/// +internal sealed class CachedMemberResultElement +{ + #region Fields + + /// + /// The index of this element within the collection. + /// + public int Index; + + /// + /// The value of this collection element. + /// + public object? Value; + + private readonly string _representation; + + #endregion + + #region Constructor + + /// + /// Initializes a new instance of the class with the specified value and index. + /// + /// The value of the collection element. + /// The zero-based index of this element within the collection. + public CachedMemberResultElement(object? value, int index) + { + Index = index; + Value = value; + + try + { + _representation = Value?.ToString() ?? "Null"; + } + catch (Exception) + { + Value = _representation = "Unavailable"; + } + } + + #endregion + + #region Overrides + + /// + /// Returns a string representation of this collection element in the format "[index]: value". + /// + /// A formatted string showing the index and value. + public override string ToString() => $"[{Index}]: {_representation}]"; + + #endregion +} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs deleted file mode 100644 index e4385e1..0000000 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs +++ /dev/null @@ -1,443 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Reflection; -using System.Text; - -using OutGridView.Models; - -using Terminal.Gui; - -namespace OutGridView.Cmdlet -{ - internal sealed class ConsoleGui : IDisposable - { - private const string FILTER_LABEL = "Filter"; - // This adjusts the left margin of all controls - private const int MARGIN_LEFT = 1; - // Width of Terminal.Gui ListView selection/check UI elements (old == 4, new == 2) - private const int CHECK_WIDTH = 2; - private bool _cancelled; - private Label _filterLabel; - private TextField _filterField; - private ListView _listView; - // _inputSource contains the full set of Input data and tracks any items the user - // marks. When the cmdlet exits, any marked items are returned. When a filter is - // active, the list view shows a copy of _inputSource that includes both the items - // matching the filter AND any items previously marked. - private GridViewDataSource _inputSource; - - // _listViewSource is a filtered copy of _inputSource that ListView.Source is set to. - // Changes to IsMarked are propogated back to _inputSource. - private GridViewDataSource _listViewSource; - private ApplicationData _applicationData; - private GridViewDetails _gridViewDetails; - - public HashSet Start(ApplicationData applicationData) - { - _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(); - _gridViewDetails = new GridViewDetails - { - // If OutputMode is Single or Multiple, then we make items selectable. If we make them selectable, - // 2 columns are required for the check/selection indicator and space. - ListViewOffset = _applicationData.OutputMode != OutputModeOption.None ? MARGIN_LEFT + CHECK_WIDTH : MARGIN_LEFT - }; - - Window win = CreateTopLevelWindow(); - - // Create the headers and calculate column widths based on the DataTable - List gridHeaders = _applicationData.DataTable.DataColumns.Select((c) => c.Label).ToList(); - CalculateColumnWidths(gridHeaders); - - // Copy the input DataTable into our master ListView source list; upon exit any items - // that are IsMarked are returned (if Outputmode is set) - _inputSource = LoadData(); - - if (!_applicationData.MinUI) - { - // Add Filter UI - AddFilter(win); - // Add Header UI - AddHeaders(win, gridHeaders); - } - - // Add ListView - AddListView(win); - - // Status bar is where our key-bindings are handled - AddStatusBar(!_applicationData.MinUI); - - // We *always* apply a filter, even if the -Filter parameter is not set or Filtering is not - // available. The ListView always shows a fitlered version of _inputSource even if there is no - // actual fitler. - ApplyFilter(); - - _listView.SetFocus(); - - // Run the GUI. - Application.Run(); - Application.Shutdown(); - - // Return results of selection if required. - HashSet selectedIndexes = new HashSet(); - if (_cancelled) - { - return selectedIndexes; - } - - // Return any items that were selected. - foreach (GridViewRow gvr in _inputSource.GridViewRowList) - { - if (gvr.IsMarked) - { - selectedIndexes.Add(gvr.OriginalIndex); - } - } - - return selectedIndexes; - } - - private GridViewDataSource LoadData() - { - var items = new List(); - int newIndex = 0; - for (int i = 0; i < _applicationData.DataTable.Data.Count; i++) - { - var dataTableRow = _applicationData.DataTable.Data[i]; - var valueList = new List(); - foreach (var dataTableColumn in _applicationData.DataTable.DataColumns) - { - string dataValue = dataTableRow.Values[dataTableColumn.ToString()].DisplayValue; - valueList.Add(dataValue); - } - - string displayString = GridViewHelpers.GetPaddedString(valueList, 0, _gridViewDetails.ListViewColumnWidths); - - items.Add(new GridViewRow - { - DisplayString = displayString, - // We use this to keep _inputSource up to date when a filter is applied - OriginalIndex = i - }); - - newIndex++; - } - - return new GridViewDataSource(items); - } - - private void ApplyFilter() - { - // The ListView is always filled with a (filtered) copy of _inputSource. - // We listen for `MarkChanged` events on this filtered list and apply those changes up to _inputSource. - - if (_listViewSource != null) - { - _listViewSource.MarkChanged -= ListViewSource_MarkChanged; - _listViewSource = null; - } - - _listViewSource = new GridViewDataSource(GridViewHelpers.FilterData(_inputSource.GridViewRowList, _applicationData.Filter ?? string.Empty)); - _listViewSource.MarkChanged += ListViewSource_MarkChanged; - _listView.Source = _listViewSource; - } - - private void ListViewSource_MarkChanged(object s, GridViewDataSource.RowMarkedEventArgs a) - { - _inputSource.GridViewRowList[a.Row.OriginalIndex].IsMarked = a.Row.IsMarked; - } - - private static void Accept() - { - Application.RequestStop(); - } - - private void Close() - { - _cancelled = true; - Application.RequestStop(); - } - - private Window CreateTopLevelWindow() - { - // Creates the top-level window to show - var win = new Window(_applicationData.Title) - { - X = _applicationData.MinUI ? -1 : 0, - Y = _applicationData.MinUI ? -1 : 0, - - // By using Dim.Fill(), it will automatically resize without manual intervention - Width = Dim.Fill(_applicationData.MinUI ? -1 : 0), - Height = Dim.Fill(_applicationData.MinUI ? -1 : 1) - }; - - if (_applicationData.MinUI) - { - win.Border.BorderStyle = BorderStyle.None; - } - - Application.Top.Add(win); - return win; - } - - private void AddStatusBar(bool visible) - { - var statusItems = new List(); - if (_applicationData.OutputMode != OutputModeOption.None) - { - // Use Key.Unknown for SPACE with no delegate because ListView already - // handles SPACE - statusItems.Add(new StatusItem(Key.Unknown, "~SPACE~ Select Item", null)); - } - - if (_applicationData.OutputMode == OutputModeOption.Multiple) - { - statusItems.Add(new StatusItem(Key.A | Key.CtrlMask, "~CTRL-A~ Select All", () => - { - // This selects only the items that match the Filter - var gvds = _listView.Source as GridViewDataSource; - gvds.GridViewRowList.ForEach(i => i.IsMarked = true); - _listView.SetNeedsDisplay(); - })); - - // Ctrl-D is commonly used in GUIs for select-none - statusItems.Add(new StatusItem(Key.D | Key.CtrlMask, "~CTRL-D~ Select None", () => - { - // This un-selects only the items that match the Filter - var gvds = _listView.Source as GridViewDataSource; - gvds.GridViewRowList.ForEach(i => i.IsMarked = false); - _listView.SetNeedsDisplay(); - })); - } - - if (_applicationData.OutputMode != OutputModeOption.None) - { - statusItems.Add(new StatusItem(Key.Enter, "~ENTER~ Accept", () => - { - if (Application.Top.MostFocused == _listView) - { - // If nothing was explicitly marked, we return the item that was selected - // when ENTER is pressed in Single mode. If something was previously selected - // (using SPACE) then honor that as the single item to return - if (_applicationData.OutputMode == OutputModeOption.Single && - _inputSource.GridViewRowList.Find(i => i.IsMarked) == null) - { - _listView.MarkUnmarkRow(); - } - Accept(); - } - else if (Application.Top.MostFocused == _filterField) - { - _listView.SetFocus(); - } - })); - } - - statusItems.Add(new StatusItem(Key.Esc, "~ESC~ Close", () => Close())); - if (_applicationData.Verbose || _applicationData.Debug) - { - statusItems.Add(new StatusItem(Key.Null, $" v{_applicationData.ModuleVersion}", null)); - statusItems.Add(new StatusItem(Key.Null, - $"{Application.Driver} v{FileVersionInfo.GetVersionInfo(Assembly.GetAssembly(typeof(Application)).Location).ProductVersion}", null)); - } - - var statusBar = new StatusBar(statusItems.ToArray()); - statusBar.Visible = visible; - Application.Top.Add(statusBar); - } - - private void CalculateColumnWidths(List gridHeaders) - { - _gridViewDetails.ListViewColumnWidths = new int[gridHeaders.Count]; - var listViewColumnWidths = _gridViewDetails.ListViewColumnWidths; - - for (int i = 0; i < gridHeaders.Count; i++) - { - listViewColumnWidths[i] = gridHeaders[i].Length; - } - - // calculate the width of each column based on longest string in each column for each row - foreach (var row in _applicationData.DataTable.Data) - { - int index = 0; - - // use half of the visible buffer height for the number of objects to inspect to calculate widths - foreach (var col in row.Values.Take(Application.Top.Frame.Height / 2)) - { - var len = col.Value.DisplayValue.Length; - if (len > listViewColumnWidths[index]) - { - listViewColumnWidths[index] = len; - } - index++; - } - } - - // if the total width is wider than the usable width, remove 1 from widest column until it fits - _gridViewDetails.UsableWidth = Application.Top.Frame.Width - MARGIN_LEFT - listViewColumnWidths.Length - _gridViewDetails.ListViewOffset; - int columnWidthsSum = listViewColumnWidths.Sum(); - while (columnWidthsSum >= _gridViewDetails.UsableWidth) - { - int maxWidth = 0; - int maxIndex = 0; - for (int i = 0; i < listViewColumnWidths.Length; i++) - { - if (listViewColumnWidths[i] > maxWidth) - { - maxWidth = listViewColumnWidths[i]; - maxIndex = i; - } - } - - listViewColumnWidths[maxIndex]--; - columnWidthsSum--; - } - } - - private void AddFilter(Window win) - { - _filterLabel = new Label(FILTER_LABEL) - { - X = MARGIN_LEFT, - Y = 0 - }; - - _filterField = new TextField(_applicationData.Filter ?? string.Empty) - { - X = Pos.Right(_filterLabel) + 1, - Y = Pos.Top(_filterLabel), - CanFocus = true, - Width = Dim.Fill() - 1 - }; - - // TextField captures Ctrl-A (select all text) and Ctrl-D (delete backwards) - // In OCGV these are used for select-all/none of items. Selecting items is more - // common than editing the filter field so we turn them off in the filter textview. - // BACKSPACE still works for delete backwards - _filterField.ClearKeybinding(Key.A | Key.CtrlMask); - _filterField.ClearKeybinding(Key.D | Key.CtrlMask); - - var filterErrorLabel = new Label(string.Empty) - { - X = Pos.Right(_filterLabel) + 1, - Y = Pos.Top(_filterLabel) + 1, - ColorScheme = Colors.Base, - Width = Dim.Fill() - _filterLabel.Text.Length - }; - - _filterField.TextChanged += (str) => - { - // str is the OLD value - string filterText = _filterField.Text?.ToString(); - try - { - filterErrorLabel.Text = " "; - filterErrorLabel.ColorScheme = Colors.Base; - filterErrorLabel.Redraw(filterErrorLabel.Bounds); - _applicationData.Filter = filterText; - ApplyFilter(); - - } - catch (Exception ex) - { - filterErrorLabel.Text = ex.Message; - filterErrorLabel.ColorScheme = Colors.Error; - filterErrorLabel.Redraw(filterErrorLabel.Bounds); - } - }; - - win.Add(_filterLabel, _filterField, filterErrorLabel); - - _filterField.Text = _applicationData.Filter ?? string.Empty; - _filterField.CursorPosition = _filterField.Text.Length; - } - - private void AddHeaders(Window win, List gridHeaders) - { - var header = new Label(GridViewHelpers.GetPaddedString( - gridHeaders, - _gridViewDetails.ListViewOffset, - _gridViewDetails.ListViewColumnWidths)); - header.X = 0; - if (_applicationData.MinUI) - { - header.Y = 0; - } - else - { - header.Y = 2; - } - win.Add(header); - - // This renders dashes under the header to make it more clear what is header and what is data - var headerLineText = new StringBuilder(); - foreach (char c in header.Text) - { - if (c.Equals(' ')) - { - headerLineText.Append(' '); - } - else - { - // When gui.cs supports text decorations, should replace this with just underlining the header - headerLineText.Append('-'); - } - } - - if (!_applicationData.MinUI) - { - var headerLine = new Label(headerLineText.ToString()) - { - X = 0, - Y = Pos.Bottom(header) - }; - win.Add(headerLine); - } - } - - private void AddListView(Window win) - { - _listView = new ListView(_inputSource); - _listView.X = MARGIN_LEFT; - if (!_applicationData.MinUI) - { - _listView.Y = Pos.Bottom(_filterLabel) + 3; // 1 for space, 1 for header, 1 for header underline - } - else - { - _listView.Y = 1; // 1 for space, 1 for header, 1 for header underline - } - _listView.Width = Dim.Fill(1); - _listView.Height = Dim.Fill(); - _listView.AllowsMarking = _applicationData.OutputMode != OutputModeOption.None; - _listView.AllowsMultipleSelection = _applicationData.OutputMode == OutputModeOption.Multiple; - _listView.AddKeyBinding(Key.Space, Command.ToggleChecked, Command.LineDown); - - win.Add(_listView); - } - - public void Dispose() - { - if (!Console.IsInputRedirected) - { - // By emitting this, we fix two issues: - // 1. An issue where arrow keys don't work in the console because .NET - // requires application mode to support Arrow key escape sequences. - // Esc[?1h sets the cursor key to application mode - // See http://ascii-table.com/ansi-escape-sequences-vt-100.php - // 2. An issue where moving the mouse causes characters to show up because - // mouse tracking is still on. Esc[?1003l turns it off. - // See https://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking - Console.Write("\u001b[?1h\u001b[?1003l"); - } - } - } -} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs index 4d6724a..3bbe962 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs @@ -4,80 +4,128 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Collections.Specialized; +using Terminal.Gui.App; +using Terminal.Gui.Views; -using NStack; +namespace Microsoft.PowerShell.ConsoleGuiTools; -using Terminal.Gui; - -namespace OutGridView.Cmdlet +/// +/// Provides a data source implementation for the grid view that manages rows and supports marking and rendering. +/// +internal sealed class GridViewDataSource : IListDataSource { - internal sealed class GridViewDataSource : IListDataSource + /// + /// Gets or sets the list of rows displayed in the grid view. + /// + public List GridViewRowList { get; set; } + + /// + /// Gets the number of rows in the data source. + /// + public int Count => GridViewRowList.Count; + + /// + /// Gets the number of rows in the data source. + /// + public int Length => GridViewRowList.Count; + + /// + /// Gets or sets a value indicating whether to suspend raising the event. + /// + public bool SuspendCollectionChangedEvent { get; set; } + +#pragma warning disable CS0067 + /// + /// Occurs when the collection changes. + /// + public event NotifyCollectionChangedEventHandler? CollectionChanged; +#pragma warning restore CS0067 + + /// + /// Initializes a new instance of the class with the specified item list. + /// + /// The list of grid view rows to display. + public GridViewDataSource(List itemList) { - public List GridViewRowList { get; set; } - - public int Count => GridViewRowList.Count; - - public GridViewDataSource(List itemList) - { - GridViewRowList = itemList; - } - - public int Length { get; } + GridViewRowList = itemList; + } - public void Render(ListView container, ConsoleDriver driver, bool selected, int item, int col, int line, int width, int start) - { - container.Move(col, line); - RenderUstr(driver, GridViewRowList[item].DisplayString, col, line, width); - } + /// + /// Renders a specific item in the list view at the specified position. + /// + /// The list view to render into. + /// A value indicating whether the item is selected. + /// The index of the item to render. + /// The column position to start rendering. + /// The line position to render on. + /// The width available for rendering. + /// The starting position within the item's display string. + public void Render(ListView listView, bool selected, int item, int col, int line, int width, int start = 0) + { + listView.Move(col - start, line); - public bool IsMarked(int item) => GridViewRowList[item].IsMarked; + var driver = Application.Driver; + var row = GridViewRowList[item]; + driver!.AddStr(row.DisplayString ?? string.Empty); + } - public void SetMark(int item, bool value) - { - var oldValue = GridViewRowList[item].IsMarked; - GridViewRowList[item].IsMarked = value; - var args = new RowMarkedEventArgs() - { - Row = GridViewRowList[item], - OldValue = oldValue - }; - MarkChanged?.Invoke(this, args); - } - - public sealed class RowMarkedEventArgs : EventArgs + /// + /// Determines whether the specified item is marked. + /// + /// The index of the item to check. + /// if the item is marked; otherwise, . + public bool IsMarked(int item) => GridViewRowList[item].IsMarked; + + /// + /// Sets the marked state of the specified item and raises the event. + /// + /// The index of the item to mark or unmark. + /// to mark the item; to unmark it. + public void SetMark(int item, bool value) + { + var oldValue = GridViewRowList[item].IsMarked; + GridViewRowList[item].IsMarked = value; + var args = new RowMarkedEventArgs { - public GridViewRow Row { get; set; } - public bool OldValue { get; set; } - - } - - public event EventHandler MarkChanged; + Row = GridViewRowList[item], + OldValue = oldValue + }; + MarkChanged?.Invoke(this, args); + } - public IList ToList() - { - return GridViewRowList; - } + /// + /// Provides data for the event. + /// + public sealed class RowMarkedEventArgs : EventArgs + { + /// + /// Gets or sets the row that was marked or unmarked. + /// + public required GridViewRow Row { get; set; } + + /// + /// Gets or sets the previous marked state of the row. + /// + public bool OldValue { get; set; } + } - // A slightly adapted method from gui.cs: https://github.com/migueldeicaza/gui.cs/blob/fc1faba7452ccbdf49028ac49f0c9f0f42bbae91/Terminal.Gui/Views/ListView.cs#L433-L461 - private static void RenderUstr(ConsoleDriver driver, ustring ustr, int col, int line, int width) - { - int used = 0; - int index = 0; - while (index < ustr.Length) - { - (var rune, var size) = Utf8.DecodeRune(ustr, index, index - ustr.Length); - var count = Rune.ColumnWidth(rune); - if (used + count > width) break; - driver.AddRune(rune); - used += count; - index += size; - } - - while (used < width) - { - driver.AddRune(' '); - used++; - } - } + /// + /// Occurs when a row's marked state changes. + /// + public event EventHandler? MarkChanged; + + /// + /// Converts the data source to a list. + /// + /// The grid view row list as an . + public IList ToList() => GridViewRowList; + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + // No resources to dispose currently } -} +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDetails.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDetails.cs index 7c660f9..532998f 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDetails.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDetails.cs @@ -1,19 +1,26 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace OutGridView.Cmdlet +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Contains layout and dimension details for rendering the grid view in the console. +/// +internal sealed class GridViewDetails { - internal sealed class GridViewDetails - { - // Contains the width of each column in the grid view. - public int[] ListViewColumnWidths { get; set; } + /// + /// Gets or sets the width of each column in the grid view. + /// + public int[]? ListViewColumnWidths { get; set; } - // Dictates where the header should actually start considering - // some offset is needed to factor in the checkboxes - public int ListViewOffset { get; set; } + /// + /// Gets or sets the offset where the header should start, accounting for space needed for checkboxes. + /// + public int ListViewOffset { get; set; } - // The width that is actually useable on the screen after - // subtracting space needed for a clean UI (spaces between columns, etc). - public int UsableWidth { get; set; } - } -} + /// + /// Gets or sets the usable width available on the screen after subtracting space needed for UI elements such as spaces + /// between columns. + /// + public int UsableWidth { get; set; } +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs index 482fe1d..44ee449 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs @@ -1,39 +1,54 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.RegularExpressions; -namespace OutGridView.Cmdlet +namespace Microsoft.PowerShell.ConsoleGuiTools { + /// + /// Provides helper methods for filtering and formatting data in the grid view. + /// internal sealed class GridViewHelpers { - // Add all items already selected plus any that match the filter - // The selected items should be at the top of the list, in their original order + /// + /// Filters a list of grid view rows based on a regular expression pattern. + /// Marked items are always included and appear first in the result, followed by unmarked items that match the filter. + /// + /// The list of rows to filter. + /// The regular expression pattern to match against the display string. If null or empty, the original list is returned. + /// A filtered list with marked items first, followed by matching unmarked items. public static List FilterData(List listToFilter, string filter) { - var filteredList = new List(); if (string.IsNullOrEmpty(filter)) { return listToFilter; } + var filteredList = new List(); filteredList.AddRange(listToFilter.Where(gvr => gvr.IsMarked)); - filteredList.AddRange(listToFilter.Where(gvr => !gvr.IsMarked && Regex.IsMatch(gvr.DisplayString, filter, RegexOptions.IgnoreCase))); + filteredList.AddRange(listToFilter.Where(gvr => !gvr.IsMarked && Regex.IsMatch(gvr.DisplayString!, filter, RegexOptions.IgnoreCase))); return filteredList; } - public static string GetPaddedString(List strings, int offset, int[] listViewColumnWidths) + public static string GetPaddedString(List? strings, int offset, int[]? listViewColumnWidths) { + if (listViewColumnWidths is null) + { + return string.Empty; + } + var builder = new StringBuilder(); if (offset > 0) { builder.Append(string.Empty.PadRight(offset)); } + if (strings == null) return builder.ToString(); for (int i = 0; i < strings.Count; i++) { if (i > 0) @@ -44,12 +59,23 @@ public static string GetPaddedString(List strings, int offset, int[] lis // Replace any newlines with encoded newline/linefeed (`n or `r) // Note we can't use Environment.Newline because we don't know that the - // Command honors that. + // command honors that. strings[i] = strings[i].Replace("\r", "`r"); strings[i] = strings[i].Replace("\n", "`n"); - // If the string won't fit in the column, append an ellipsis. - if (strings[i].Length > listViewColumnWidths[i]) + // If the string doesn't fit in the column, append an ellipsis. + // Guard against negative or very small column widths + if (listViewColumnWidths[i] <= 0) + { + // Skip columns with zero or negative width (but separator already added above) + } + else if (listViewColumnWidths[i] < 4) + { + // For very small columns (< 4), just truncate without ellipsis + var truncateLength = Math.Min(strings[i].Length, listViewColumnWidths[i]); + builder.Append(strings[i], 0, truncateLength); + } + else if (strings[i].Length > listViewColumnWidths[i]) { builder.Append(strings[i], 0, listViewColumnWidths[i] - 3); builder.Append("..."); @@ -63,4 +89,4 @@ public static string GetPaddedString(List strings, int offset, int[] lis return builder.ToString(); } } -} +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewRow.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewRow.cs index 2ecc80e..6db806c 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewRow.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewRow.cs @@ -1,13 +1,31 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace OutGridView.Cmdlet +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Represents a single row in the grid view, including its display text, marked state, and original position. +/// +public class GridViewRow { - public class GridViewRow - { - public string DisplayString { get; set; } - public bool IsMarked { get; set; } - public int OriginalIndex { get; set; } - public override string ToString() => DisplayString; - } -} + /// + /// Gets or sets the formatted string to display for this row in the grid view. + /// + public string? DisplayString { get; set; } + + /// + /// Gets or sets a value indicating whether this row is marked (selected) by the user. + /// + public bool IsMarked { get; set; } + + /// + /// Gets or sets the original index of this row in the source data before any filtering or sorting. + /// + public int OriginalIndex { get; set; } + + /// + /// Returns the display string representation of this row. + /// + /// The value. + public override string ToString() => DisplayString ?? string.Empty; +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Header.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/Header.cs new file mode 100644 index 0000000..dde19ab --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Header.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.PortableExecutable; +using Terminal.Gui.Drawing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// A specialized view for displaying grid column headers with individual subviews for each column. +/// +internal sealed class Header : View +{ + public Header() + { + Height = 1; + CanFocus = false; + Width = Dim.Fill(); + } + + public override void EndInit() + { + base.EndInit(); + + + // We are a subview of the ListView.Padding. + if (SuperView is Padding padding) + { + padding.Parent?.ViewportChanged += ListViewOnViewportChanged; + } + + void ListViewOnViewportChanged(object? sender, DrawEventArgs e) + { + if (sender is ListView listView) + Viewport = Viewport with { X = listView.Viewport.X }; + } + } + + protected override void OnSubViewLayout(LayoutEventArgs args) + { + if (SuperView is Padding { Parent: ListView listView }) + SetContentSize(GetContentSize() with { Width = listView.GetContentSize().Width }); + + base.OnSubViewLayout(args); + } + + /// + /// Updates the header with new column strings and widths. + /// + /// The list of header strings to display. + /// The width of each column. + public void SetHeaders(List? headers, int[]? columnWidths) + { + if (headers == null || columnWidths == null) + return; + + // Clear existing labels + RemoveAll(); + + // Create a label for each header + var currentX = 0; + for (var i = 0; i < headers.Count; i++) + { + // Skip columns with zero width + if (columnWidths[i] <= 0) + continue; + + var column = new View + { + Text = headers[i], + X = currentX, + Y = 0, + Width = Dim.Auto(DimAutoStyle.Text), + Height = 1, + TextAlignment = Alignment.Start, + VerticalTextAlignment = Alignment.Start + }; + column.GettingAttributeForRole += ColumnOnGettingAttributeForRole; + + void ColumnOnGettingAttributeForRole(object? sender, VisualRoleEventArgs e) + { + if (e.Role == VisualRole.Normal) + { + e.Result = e.Result!.Value with { Style = TextStyle.Bold | TextStyle.Underline }; + e.Handled = true; + } + } + + Add(column); + + // Move to next column position (width + 1 space separator) + currentX += columnWidths[i] + 1; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj b/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj index ed25247..9f47c79 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj @@ -2,6 +2,8 @@ net8.0 + preview + enable @@ -15,13 +17,13 @@ - - + + - + @@ -33,4 +35,8 @@ true Recommended + + + true + diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.psd1 b/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.psd1 index 80c0fe8..4454fc5 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.psd1 +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.psd1 @@ -9,7 +9,7 @@ RootModule = 'Microsoft.PowerShell.ConsoleGuiTools.dll' # Version number of this module. -ModuleVersion = '0.7.7' +ModuleVersion = '0.9.0' # Supported PSEditions CompatiblePSEditions = @( 'Core' ) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridView.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridView.cs new file mode 100644 index 0000000..87e1713 --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridView.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Microsoft.PowerShell.OutGridView.Models; +using Terminal.Gui.App; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Provides the main orchestration for the Out-ConsoleGridView cmdlet, managing the Terminal.Gui application lifecycle +/// and coordinating between the application data and the grid view window. +/// +/// +/// This class serves as a facade that initializes the Terminal.Gui framework, creates and runs the grid view window, +/// and handles cleanup operations. It delegates the actual UI rendering and user interaction to the +/// class. +/// +internal sealed class OutConsoleGridView : IDisposable +{ + private bool _cancelled; + private ApplicationData? _applicationData; + + /// + /// Runs the grid view Terminal.Gui Application with the specified configuration and returns the indexes of selected items. + /// + /// + /// The application configuration containing the data table, output mode, filter settings, and other display options. + /// + /// + /// A containing the zero-based indexes of items selected by the user. + /// Returns an empty set if the user cancels the operation or if no items were selected. + /// + public HashSet Run(ApplicationData applicationData) + { + _applicationData = applicationData; + if (_applicationData.UseNetDriver) Application.ForceDriver = "NetDriver"; + + var window = new OutGridViewWindow(_applicationData); + try + { + Application.Init(); + Application.Run(window); + _cancelled = window.Cancelled; + return window.GetSelectedIndexes(); + } + finally + { + window?.Dispose(); + Application.Shutdown(); + } + } + + public void Dispose() + { + if (!Console.IsInputRedirected) + // By emitting this, we fix two issues: + // 1. An issue where arrow keys don't work in the console because .NET + // requires application mode to support Arrow key escape sequences. + // Esc[?1h sets the cursor key to application mode + // See http://ascii-table.com/ansi-escape-sequences-vt-100.php + // 2. An issue where moving the mouse causes characters to show up because + // mouse tracking is still on. Esc[?1003l turns it off. + // See https://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking + Console.Write("\u001b[?1h\u001b[?1003l"); + } +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs index 94bb7e1..8e3cce9 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs @@ -4,184 +4,195 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using System.Management.Automation; using System.Management.Automation.Internal; +using Microsoft.PowerShell.OutGridView.Models; -using OutGridView.Models; +namespace Microsoft.PowerShell.ConsoleGuiTools; -namespace OutGridView.Cmdlet +/// +/// Sends output to an interactive table in a separate console window. This class is invoked by PowerShell when the +/// Out-ConsoleGridView cmdlet is called. +/// +[Cmdlet(VerbsData.Out, "ConsoleGridView")] +[Alias("ocgv")] +public class OutConsoleGridViewCmdletCommand : PSCmdlet, IDisposable { - [Cmdlet(VerbsData.Out, "ConsoleGridView")] - [Alias("ocgv")] - public class OutConsoleGridViewCmdletCommand : PSCmdlet, IDisposable + #region Properties + + private const string DATA_NOT_QUALIFIED_FOR_GRID_VIEW = nameof(DATA_NOT_QUALIFIED_FOR_GRID_VIEW); + private const string ENVIRONMENT_NOT_SUPPORTED_FOR_GRID_VIEW = nameof(ENVIRONMENT_NOT_SUPPORTED_FOR_GRID_VIEW); + + private readonly List _psObjects = []; + private readonly OutConsoleGridView _outConsoleGridView = new(); + + #endregion Properties + + #region Input Parameters + + /// + /// Gets or sets the current pipeline object. + /// + [Parameter(ValueFromPipeline = true, HelpMessage = "Specifies the input pipeline object")] + public PSObject InputObject { get; set; } = AutomationNull.Value; + + /// + /// Gets or sets the title of the Out-ConsoleGridView window. + /// + [Parameter(HelpMessage = + "Specifies the text that appears in the title bar of the Out-ConsoleGridView window. By default, the title bar displays the command that invokes Out-ConsoleGridView.")] + [ValidateNotNullOrEmpty] + public string? Title { get; set; } + + /// + /// Gets or sets a value indicating whether the selected items should be written to the pipeline + /// and if it should be possible to select multiple or single list items. + /// + [Parameter(HelpMessage = + "Determines whether a single item (Single), multiple items (Multiple; default), or no items (None) will be written to the pipeline. Also determines selection behavior in the GUI.")] + public OutputModeOption OutputMode { set; get; } = OutputModeOption.Multiple; + + /// + /// 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 a value indicating 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 a value indicating whether the Terminal.Gui System.Net.Console-based Driver will be used + /// instead of the + /// default platform-specific (Windows or Curses) Driver. + /// + [Parameter(HelpMessage = + "If specified the Terminal.Gui System.Net.Console-based Driver (NetDriver) will be used.")] + public SwitchParameter UseNetDriver { set; get; } + + /// + /// Gets or sets a value indicating whether all properties should be displayed instead of just the default display properties. + /// + [Parameter(HelpMessage = + "If specified, all properties of the objects will be displayed instead of just the default display properties.")] + public SwitchParameter AllProperties { set; get; } + + /// + /// Gets a value indicating whether the Verbose switch is present. + /// + public bool Verbose => MyInvocation.BoundParameters.ContainsKey("Verbose"); + + /// + /// Gets a value indicating whether the Debug switch is present. + /// + public bool Debug => MyInvocation.BoundParameters.ContainsKey("Debug"); + + #endregion Input Parameters + + /// + /// Performs initialization of command execution. Validates that the environment supports grid view. + /// + protected override void BeginProcessing() { - #region Properties - - private const string DataNotQualifiedForGridView = nameof(DataNotQualifiedForGridView); - private const string EnvironmentNotSupportedForGridView = nameof(EnvironmentNotSupportedForGridView); - - private List _psObjects = new List(); - private ConsoleGui _consoleGui = new ConsoleGui(); - - #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; } - - /// - /// Get or sets a value indicating whether the selected items should be written to the pipeline - /// and if it should be possible to select multiple or single list items. - /// - [Parameter(HelpMessage = "Determines whether a single item (Single), multiple items (Multiple; default), or no items (None) will be written to the pipeline. Also determines selection behavior in the GUI.")] - public OutputModeOption OutputMode { set; get; } = OutputModeOption.Multiple; - - /// - /// 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 -Verbose switch - /// - public bool Verbose => MyInvocation.BoundParameters.TryGetValue("Verbose", out var o); - - /// - /// 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) { - if (Console.IsInputRedirected) - { - ErrorRecord error = new ErrorRecord( - new PSNotSupportedException("Not supported in this environment (when input is redirected)."), - EnvironmentNotSupportedForGridView, - ErrorCategory.NotImplemented, - null); - - ThrowTerminatingError(error); - } - } + var error = new ErrorRecord( + new PSNotSupportedException("Not supported in this environment (when input is redirected)."), + ENVIRONMENT_NOT_SUPPORTED_FOR_GRID_VIEW, + ErrorCategory.NotImplemented, + null); - // 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); - } + ThrowTerminatingError(error); } + } - private void ProcessObject(PSObject input) + /// + /// Processes each input object received from the pipeline. + /// + protected override void ProcessRecord() + { + if (Equals(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); + } + + /// + /// Processes a single object for display in the grid view. + /// + /// The PSObject to process. + /// Thrown when the data type is not supported for Out-ConsoleGridView. + private void ProcessObject(PSObject input) + { + var 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) { + var error = new ErrorRecord( + new FormatException("Invalid data type for Out-ConsoleGridView"), + DATA_NOT_QUALIFIED_FOR_GRID_VIEW, + ErrorCategory.InvalidType, + null); - object baseObject = input.BaseObject; + ThrowTerminatingError(error); + } - // 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 Out-ConsoleGridView"), - DataNotQualifiedForGridView, - ErrorCategory.InvalidType, - null); + _psObjects.Add(input); + } - ThrowTerminatingError(error); - } + /// + /// Performs final processing after all pipeline objects have been received. + /// Displays the console grid view and writes selected objects to the pipeline. + /// + protected override void EndProcessing() + { + base.EndProcessing(); - _psObjects.Add(input); - } + // Return if no objects + if (_psObjects.Count == 0) return; - // 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() + var applicationData = new ApplicationData { - base.EndProcessing(); - - //Return if no objects - if (_psObjects.Count == 0) - { - return; - } - - var TG = new TypeGetter(this); - - var dataTable = TG.CastObjectsToTableView(_psObjects); - var applicationData = new ApplicationData - { - Title = Title ?? "Out-ConsoleGridView", - OutputMode = OutputMode, - Filter = Filter, - MinUI = MinUI, - DataTable = dataTable, - UseNetDriver = UseNetDriver, - Verbose = Verbose, - Debug = Debug, - ModuleVersion = MyInvocation.MyCommand.Version.ToString() - }; - - - var selectedIndexes = _consoleGui.Start(applicationData); - foreach (int idx in selectedIndexes) - { - var selectedObject = _psObjects[idx]; - if (selectedObject == null) - { - continue; - } - WriteObject(selectedObject, false); - } - } - - public void Dispose() + PSObjects = _psObjects.Cast().ToList(), + Title = Title ?? "Out-ConsoleGridView", + OutputMode = OutputMode, + Filter = Filter, + MinUI = MinUI, + UseNetDriver = UseNetDriver, + AllProperties = AllProperties, + Verbose = Verbose, + Debug = Debug, + ModuleVersion = MyInvocation.MyCommand.Version.ToString() + }; + + var selectedIndexes = _outConsoleGridView.Run(applicationData); + foreach (var idx in selectedIndexes) { - _consoleGui.Dispose(); - GC.SuppressFinalize(this); + var selectedObject = _psObjects[idx]; + + WriteObject(selectedObject, false); } } -} + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + _outConsoleGridView.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs new file mode 100644 index 0000000..b15104e --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs @@ -0,0 +1,515 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Linq; +using System.Management.Automation; +using System.Reflection; +using System.Text.RegularExpressions; +using Microsoft.PowerShell.OutGridView.Models; +using Terminal.Gui.App; +using Terminal.Gui.Drawing; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Provides the Terminal.Gui Window implementation for displaying tabular data with filtering and selection +/// capabilities. +/// +internal sealed class OutGridViewWindow : Window +{ + private const string FILTER_LABEL = "_Filter"; + private const int MARGIN_LEFT = 0; + private const int CHECK_WIDTH = 2; + private readonly ApplicationData _applicationData; + private readonly DataTable? _dataTable; + private readonly GridViewDetails _gridViewDetails; + private View? _filterErrorView; + private TextField? _filterField; + + private Label? _filterLabel; + private Header? _header; + private GridViewDataSource? _inputSource; + private ListView? _listView; + private GridViewDataSource? _listViewSource; + private int[]? _naturalColumnWidths; + private StatusBar? _statusBar; + + /// + /// Initializes a new instance of the class with the specified application data. + /// + /// The configuration and data to display in the grid view. + public OutGridViewWindow(ApplicationData applicationData) + { + _applicationData = applicationData; + Title = _applicationData.Title ?? "Out-ConsoleGridView"; + + if (_applicationData.MinUI) + BorderStyle = LineStyle.None; + + _gridViewDetails = new GridViewDetails + { + ListViewOffset = _applicationData.OutputMode != OutputModeOption.None + ? MARGIN_LEFT + CHECK_WIDTH + : MARGIN_LEFT + }; + + // Convert PSObjects to DataTable using TypeGetter which handles format data properly + if (_applicationData.PSObjects is { Count: > 0 }) + { + var psObjects = _applicationData.PSObjects.Cast().ToList(); + _dataTable = TypeGetter.CastObjectsToTableView(psObjects, _applicationData.AllProperties); + } + else + { + _dataTable = new DataTable([], []); + } + + if (!_applicationData.MinUI) AddFilter(); + + AddListView(); + AddStatusBar(); + + _listView?.SetFocus(); + + // Copy the input DataTable into our master ListView source list + _inputSource = LoadData(); + ApplyFilter(); + _gridViewDetails.UsableWidth = _naturalColumnWidths!.Sum(); + var gridHeaders = _dataTable?.DataColumns.Select(c => c.Label).ToList(); + + _header?.SetHeaders(gridHeaders, _gridViewDetails.ListViewColumnWidths); + } + + /// + /// Gets a value indicating whether the user cancelled the operation. + /// + public bool Cancelled { get; private set; } + + /// + /// Gets the original indexes of all marked rows. + /// + /// A set of zero-based indexes from the original data table. + public HashSet GetSelectedIndexes() + { + var selectedIndexes = new HashSet(); + if (Cancelled || _inputSource == null) return selectedIndexes; + + foreach (var gvr in _inputSource.GridViewRowList) + if (gvr.IsMarked) + selectedIndexes.Add(gvr.OriginalIndex); + + return selectedIndexes; + } + + #region Data Management + + /// + /// Loads data from the application data table into grid view rows. + /// + /// A data source containing the loaded rows. + private GridViewDataSource LoadData() + { + var items = new List(); + if (_dataTable?.Data.Count == 0) + return new GridViewDataSource(items); + + // Calculate and cache natural column widths + _naturalColumnWidths = CalculateNaturalColumnWidths(_dataTable?.DataColumns.Select(c => c.Label).ToList()); + _gridViewDetails.ListViewColumnWidths = _naturalColumnWidths; + + for (var i = 0; i < _dataTable?.Data.Count; i++) + { + var dataTableRow = _dataTable.Data[i]; + var valueList = new List(); + foreach (var dataTableColumn in _dataTable.DataColumns) + { + var columnKey = dataTableColumn.ToString(); + + // Check if the key exists in the dictionary + valueList.Add(dataTableRow.Values.TryGetValue(columnKey, out var value) + ? value.DisplayValue + // Key not found - this means the dictionary was populated with different keys + // This is a bug - let's add empty string for now to avoid crash + : string.Empty); + } + + var displayString = GridViewHelpers.GetPaddedString(valueList, 0, _gridViewDetails.ListViewColumnWidths); + + items.Add(new GridViewRow + { + DisplayString = displayString, + OriginalIndex = i + }); + } + + return new GridViewDataSource(items); + } + + #endregion + + #region Filtering + + /// + /// Applies the current filter to the input data and updates the list view with matching rows. + /// + private void ApplyFilter() + { + GridViewRow? selectedItem = null; + + if (_listViewSource != null) + { + selectedItem = _listViewSource.GridViewRowList.ElementAtOrDefault(_listView?.SelectedItem ?? 0); + _listViewSource.MarkChanged -= OnListViewSourceMarkChanged; + _listViewSource = null; + } + + _inputSource ??= LoadData(); + + try + { + _listViewSource = new GridViewDataSource(GridViewHelpers.FilterData(_inputSource.GridViewRowList, + _applicationData.Filter ?? string.Empty)); + } + catch (RegexParseException ex) + { + if (_filterErrorView != null) + _filterErrorView.Text = ex.Message; + } + + _listViewSource?.MarkChanged += OnListViewSourceMarkChanged; + + _listView?.Source = _listViewSource; + + if (selectedItem is not null && _listViewSource != null) + { + var newIndex = + _listViewSource.GridViewRowList.FindIndex(i => i.OriginalIndex == selectedItem.OriginalIndex); + if (newIndex >= 0 && _listView != null) + _listView.SelectedItem = newIndex; + } + + if (_listView?.SelectedItem == -1 && _listView != null) + _listView.SelectedItem = 0; + } + + /// + /// Handles mark changed events from the filtered list view and propagates changes to the input source. + /// + /// The event sender. + /// The event arguments containing the row that was marked or unmarked. + private void OnListViewSourceMarkChanged(object? s, GridViewDataSource.RowMarkedEventArgs a) + { + if (_inputSource != null) + _inputSource.GridViewRowList[a.Row.OriginalIndex].IsMarked = a.Row.IsMarked; + } + + #endregion + + #region User Actions + + /// + /// Reloads the data with the specified AllProperties setting. + /// + private void ReloadDataWithAllProperties(bool allProperties) + { + _applicationData.AllProperties = allProperties; + + // Recreate the data table with the new property settings + DataTable newDataTable; + if (_applicationData.PSObjects is { Count: > 0 }) + { + var psObjects = _applicationData.PSObjects.Cast().ToList(); + newDataTable = TypeGetter.CastObjectsToTableView(psObjects, allProperties); + } + else + { + newDataTable = new DataTable([], []); + } + + // Update the data table reference + typeof(OutGridViewWindow) + .GetField("_dataTable", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(this, newDataTable); + + // Recalculate column widths + var gridHeaders = newDataTable.DataColumns.Select(c => c.Label).ToList(); + _naturalColumnWidths = CalculateNaturalColumnWidths(gridHeaders); + _gridViewDetails.ListViewColumnWidths = _naturalColumnWidths; + _gridViewDetails.UsableWidth = _naturalColumnWidths.Sum(); + + // Update header + _header?.SetHeaders(gridHeaders, _gridViewDetails.ListViewColumnWidths); + + // Reload and reapply filter + _inputSource = LoadData(); + ApplyFilter(); + + // Update content size + _listView?.SetContentSize(new Size(_naturalColumnWidths.Sum(), _listView.GetContentSize().Height)); + + // Update status bar to show current state + UpdateStatusBar(); + + // Force redraw + SetNeedsLayout(); + SetNeedsDraw(); + } + + /// + /// Updates the status bar to reflect the current AllProperties state. + /// + private void UpdateStatusBar() + { + if (_statusBar == null) return; + + // Remove and recreate status bar to update the checkbox text + Remove(_statusBar); + AddStatusBar(); + } + + /// + /// Accepts the current selection and closes the window. + /// + private static void Accept() + { + Application.RequestStop(); + } + + /// + /// Cancels the operation and closes the window. + /// + private void Close() + { + Cancelled = true; + Application.RequestStop(); + } + + #endregion + + #region UI Construction + + /// + /// Adds the filter text field and error display to the window. + /// + private void AddFilter() + { + _filterLabel = new Label + { + Text = FILTER_LABEL, + X = MARGIN_LEFT, + Y = 0 + }; + + _filterField = new TextField + { + Text = _applicationData.Filter ?? string.Empty, + X = Pos.Right(_filterLabel) + 1, + Y = Pos.Top(_filterLabel), + CanFocus = true, + Width = Dim.Fill() - 1 + }; + + _filterField.KeyBindings.Remove(Key.A.WithCtrl); + _filterField.KeyBindings.Remove(Key.D.WithCtrl); + + _filterErrorView = new View + { + Text = string.Empty, + X = Pos.Right(_filterLabel) + 1, + Y = Pos.Top(_filterLabel) + 1, + Width = Dim.Fill() - _filterLabel.Text.Length, + Height = Dim.Auto(DimAutoStyle.Text), + SchemeName = "Error" + }; + + _filterField.TextChanged += (_, _) => + { + var filterText = _filterField.Text; + try + { + _filterErrorView.Text = string.Empty; + _applicationData.Filter = filterText; + ApplyFilter(); + } + catch (Exception ex) + { + _filterErrorView.Text = ex.Message; + } + }; + + Add(_filterLabel, _filterField, _filterErrorView); + + _filterField.Text = _applicationData.Filter ?? string.Empty; + _filterField.CursorPosition = _filterField.Text.Length; + } + + /// + /// Adds the main list view control to the window with configured selection behavior. + /// + private void AddListView() + { + _listView = new ListView + { + Source = _inputSource, + X = MARGIN_LEFT, + Y = !_applicationData.MinUI ? Pos.Bottom(_filterErrorView!) : 1, + Width = Dim.Fill(), + Height = Dim.Fill(1), + AllowsMarking = _applicationData.OutputMode != OutputModeOption.None, + AllowsMultipleSelection = _applicationData.OutputMode == OutputModeOption.Multiple, + SelectedItem = 0, + VerticalScrollBar = { AutoShow = true }, + HorizontalScrollBar = { AutoShow = true } + }; + + _listView.KeyBindings.Remove(Key.A.WithCtrl); + + if (!_applicationData.MinUI) AddHeader(); + + Add(_listView); + return; + + void AddHeader() + { + _header = new Header + { + X = CHECK_WIDTH + }; + + + _listView.Padding!.Thickness = _listView.Padding.Thickness with { Top = 1 }; + _listView!.Padding!.Add(_header); + _listView.VerticalScrollBar.Y = 1; + } + } + + + /// + /// Adds the status bar with keyboard shortcuts to the window. + /// + private void AddStatusBar() + { + var shortcuts = new List(); + if (_applicationData.OutputMode != OutputModeOption.None) + shortcuts.Add(new Shortcut(Key.Space, "Select", null)); + + if (_applicationData.OutputMode == OutputModeOption.Multiple) + { + shortcuts.Add(new Shortcut(Key.A.WithCtrl, "Sel. All", () => + { + _listView?.MarkAll(true); + _listView?.SetNeedsDraw(); + })); + + shortcuts.Add(new Shortcut(Key.D.WithCtrl, "Sel. None", () => + { + _listView?.MarkAll(false); + _listView?.SetNeedsDraw(); + })); + } + + if (_applicationData.OutputMode != OutputModeOption.None) + shortcuts.Add(new Shortcut(Key.Enter, "Accept", () => + { + if (Application.Top?.MostFocused == _listView) + { + if (_applicationData.OutputMode == OutputModeOption.Single && + _inputSource!.GridViewRowList.Find(i => i.IsMarked) == null) + if (_listView!.SelectedItem >= 0 && _listView.SelectedItem < _listViewSource!.Count) + { + var item = _listViewSource.GridViewRowList[_listView.SelectedItem]; + item.IsMarked = !item.IsMarked; + } + + Accept(); + } + else if (Application.Top?.MostFocused == _filterField) + { + _listView!.SetFocus(); + } + })); + + shortcuts.Add(new Shortcut(Key.Esc, "Close", Close)); + + var allPropertiesShortcut = new Shortcut + { + CommandView = new CheckBox + { + Title = "A_ll Properties", + CheckedState = _applicationData.AllProperties ? CheckState.Checked : CheckState.UnChecked, + CanFocus = false, + HighlightStates = MouseState.None + }, + CanFocus = false, + BindKeyToApplication = true + }; + + allPropertiesShortcut.Accepting += (_, e) => + { + ReloadDataWithAllProperties(!_applicationData.AllProperties); + e.Handled = true; + }; + + shortcuts.Add(allPropertiesShortcut); + + + if (_applicationData.Verbose || _applicationData.Debug) + { + shortcuts.Add(new Shortcut(Key.Empty, $" v{_applicationData.ModuleVersion}", null)); + shortcuts.Add(new Shortcut(Key.Empty, + $"{Application.Driver} v{FileVersionInfo.GetVersionInfo(Assembly.GetAssembly(typeof(Application))!.Location).ProductVersion}", + null)); + } + + _statusBar = new StatusBar(shortcuts); + Add(_statusBar); + } + + #endregion + + #region Layout Calculation + + protected override void OnSubViewsLaidOut(LayoutEventArgs args) + { + base.OnSubViewsLaidOut(args); + _listView?.SetContentSize(new Size(_naturalColumnWidths!.Sum(), _listView.GetContentSize().Height)); + } + + /// + /// Calculates the natural column widths needed to display all data without truncation. + /// + /// The column headers for the grid. + /// An array of column widths where each width is the maximum needed for that column. + private int[] CalculateNaturalColumnWidths(List? gridHeaders) + { + if (gridHeaders is null || _dataTable is null) + return []; + + var columnWidths = new int[gridHeaders.Count]; + + // Start with header widths + for (var i = 0; i < gridHeaders.Count; i++) + columnWidths[i] = gridHeaders[i].Length; + + // Expand to fit data + foreach (var row in _dataTable.Data) + for (var i = 0; i < _dataTable.DataColumns.Count; i++) + { + var columnKey = _dataTable.DataColumns[i].ToString(); + if (row.Values.TryGetValue(columnKey, out var value)) + { + var len = value.DisplayValue.Length; + if (len > columnWidths[i]) + columnWidths[i] = len; + } + } + + return columnWidths; + } + + #endregion +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json new file mode 100644 index 0000000..ca80d07 --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json @@ -0,0 +1,51 @@ +{ + "profiles": { + "Microsoft.PowerShell.ConsoleGuiTools": { + "commandName": "Project", + "commandLineArgs": "C:\\Program Files\\PowerShell\\7\\pwsh.exe -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView }\"", + "workingDirectory": "$(TargetDir)" + }, + "OCGV -Filter": { + "commandName": "Executable", + "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView -Filter com }\"", + "workingDirectory": "$(TargetDir)" + }, + "OCGV": { + "commandName": "Executable", + "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-NetAdapter | Out-ConsoleGridView}\"", + "workingDirectory": "$(TargetDir)" + }, + "OCGV -MinUi": { + "commandName": "Executable", + "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView -MinUi }\"", + "workingDirectory": "$(TargetDir)" + }, + "SHOT": { + "commandName": "Executable", + "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Show-ObjectTree }\"", + "workingDirectory": "$(TargetDir)" + }, + "OCGV Select-Object": { + "commandName": "Executable", + "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Select-Object ProcessName, Id, Handles, NPM, PM, WS, CPU | Out-ConsoleGridView}\"", + "workingDirectory": "$(TargetDir)" + }, + "gci | OCGV ": { + "commandName": "Executable", + "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; gci | Out-ConsoleGridView}\"", + "workingDirectory": "$(TargetDir)" + }, + "OCGV -AllProperties": { + "commandName": "Executable", + "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView -AllProperties}\"", + "workingDirectory": "$(TargetDir)" + } + } +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/RegexTreeViewTextFilter.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/RegexTreeViewTextFilter.cs new file mode 100644 index 0000000..a7574dd --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/RegexTreeViewTextFilter.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Text.RegularExpressions; +using Terminal.Gui.Views; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Provides regex-based filtering for a TreeView, allowing users to filter tree nodes by matching their display text against a regular expression pattern. +/// +/// The parent ShowObjectTreeView that owns this filter. +/// The TreeView to apply filtering to. +internal sealed class RegexTreeViewTextFilter(ShowObjectTreeWindow parent, TreeView forTree) : ITreeViewFilter +{ + #region Fields + + private readonly TreeView _forTree = forTree ?? throw new ArgumentNullException(nameof(forTree)); + private string _text = string.Empty; + + #endregion + + #region Properties + + /// + /// Gets or sets the regex pattern text used for filtering. + /// + public string Text + { + get => _text; + set + { + _text = value; + RefreshTreeView(); + } + } + + #endregion + + #region ITreeViewFilter Implementation + + /// + /// Determines whether the specified model object matches the current filter criteria. + /// + /// The model object to test against the filter. + /// if the object matches the filter or no filter is set; otherwise, . + public bool IsMatch(object model) + { + if (string.IsNullOrWhiteSpace(Text)) + return true; + + var modelText = _forTree.AspectGetter(model); + try + { + var isMatch = Regex.IsMatch(modelText ?? string.Empty, Text, RegexOptions.IgnoreCase); + parent.SetRegexError(string.Empty); + return isMatch; + } + catch (RegexParseException e) + { + parent.SetRegexError(e.Message); + return false; + } + } + + #endregion + + #region Private Methods + + /// + /// Refreshes the tree view to apply the updated filter. + /// + private void RefreshTreeView() + { + _forTree.InvalidateLineMap(); + _forTree.SetNeedsDraw(); + } + + #endregion +} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs index 5b2f95d..00da967 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs @@ -4,153 +4,162 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using System.Management.Automation; using System.Management.Automation.Internal; - -using OutGridView.Models; - -namespace OutGridView.Cmdlet +using Microsoft.PowerShell.OutGridView.Models; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Displays objects in a hierarchical tree view in a separate console window. This class is invoked by PowerShell when +/// the +/// Show-ObjectTree cmdlet is called. +/// +[Cmdlet("Show", "ObjectTree")] +[Alias("shot")] +public class ShowObjectTreeCmdletCommand : PSCmdlet, IDisposable { - [Cmdlet("Show", "ObjectTree")] - [Alias("shot")] - public class ShowObjectTreeCmdletCommand : PSCmdlet, IDisposable + #region Properties + + private const string DATA_NOT_QUALIFIED_FOR_SHOW_OBJECT_TREE = nameof(DATA_NOT_QUALIFIED_FOR_SHOW_OBJECT_TREE); + + private const string ENVIRONMENT_NOT_SUPPORTED_FOR_SHOW_OBJECT_TREE = + nameof(ENVIRONMENT_NOT_SUPPORTED_FOR_SHOW_OBJECT_TREE); + + private readonly List _psObjects = []; + + #endregion Properties + + #region Input Parameters + + /// + /// Gets or sets the current pipeline object. + /// + [Parameter(ValueFromPipeline = true, HelpMessage = "Specifies the input pipeline object")] + public PSObject InputObject { get; set; } = AutomationNull.Value; + + /// + /// Gets or sets the title of the Show-ObjectTree window. + /// + [Parameter(HelpMessage = + "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.")] + [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 a value indicating 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 a value indicating 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; } + + /// + /// Gets a value indicating whether the Debug switch is present. + /// + public bool Debug => MyInvocation.BoundParameters.ContainsKey("Debug"); + + #endregion Input Parameters + + /// + /// Performs initialization of command execution. Validates that the environment supports object tree view. + /// + protected override void BeginProcessing() { - #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) { - if (Console.IsInputRedirected) - { - ErrorRecord error = new ErrorRecord( - new PSNotSupportedException("Not supported in this environment (when input is redirected)."), - EnvironmentNotSupportedForShowObjectTree, - ErrorCategory.NotImplemented, - null); - - ThrowTerminatingError(error); - } - } + var error = new ErrorRecord( + new PSNotSupportedException("Not supported in this environment (when input is redirected)."), + ENVIRONMENT_NOT_SUPPORTED_FOR_SHOW_OBJECT_TREE, + ErrorCategory.NotImplemented, + null); - // 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); - } + ThrowTerminatingError(error); } + } + + /// + /// Processes each input object received from the pipeline. + /// + protected override void ProcessRecord() + { + if (Equals(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) + { + var baseObject = input.BaseObject; - private void ProcessObject(PSObject input) + // Throw a terminating error for types that are not supported. + if (baseObject is ScriptBlock || + baseObject is SwitchParameter || + baseObject is PSReference || + baseObject is PSObject) { + var error = new ErrorRecord( + new FormatException("Invalid data type for Show-ObjectTree"), + DATA_NOT_QUALIFIED_FOR_SHOW_OBJECT_TREE, + ErrorCategory.InvalidType, + null); - object baseObject = input.BaseObject; + ThrowTerminatingError(error); + } - // 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); + _psObjects.Add(input); + } - ThrowTerminatingError(error); - } + /// + /// Performs final processing after all pipeline objects have been received. + /// Displays the object tree view with all collected objects. + /// + protected override void EndProcessing() + { + base.EndProcessing(); - _psObjects.Add(input); - } + // Return if no objects + if (_psObjects.Count == 0) return; - // 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() + var applicationData = new ApplicationData { - 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); - } + PSObjects = _psObjects.Cast().ToList(), + Title = Title ?? "Show-ObjectTree", + Filter = Filter, + MinUI = MinUI, + UseNetDriver = UseNetDriver, + Debug = Debug, + ModuleVersion = MyInvocation.MyCommand.Version.ToString() + }; + + ShowObjectView.Run(applicationData); + } - public void Dispose() - { - GC.SuppressFinalize(this); - } + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + GC.SuppressFinalize(this); } -} +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeWindow.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeWindow.cs new file mode 100644 index 0000000..d91a87b --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeWindow.cs @@ -0,0 +1,353 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Reflection; +using System.Text.RegularExpressions; +using Microsoft.PowerShell.OutGridView.Models; +using Terminal.Gui.App; +using Terminal.Gui.Drawing; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Provides the Terminal.Gui Window implementation for displaying object hierarchies in a tree view with filtering capabilities. +/// +internal sealed class ShowObjectTreeWindow : Window, ITreeBuilder +{ + #region Fields + + private readonly TreeView _tree; + private readonly View _filterErrorView; + private Shortcut _selectedShortcut; + private readonly StatusBar _statusBar; + private readonly ApplicationData _applicationData; + + #endregion + + #region Properties + + /// + /// Gets a value indicating whether this tree builder supports the CanExpand operation. + /// + public bool SupportsCanExpand => true; + + #endregion + + #region Constructor + + /// + /// Initializes a new instance of the class with the specified application data. + /// + /// The configuration and PSObjects to display. + public ShowObjectTreeWindow(ApplicationData applicationData) + { + _applicationData = applicationData; + Title = _applicationData.Title ?? "Show-ObjectView"; + Width = Dim.Fill(); + Height = Dim.Fill(1); + Modal = false; + + if (_applicationData.MinUI) + { + BorderStyle = LineStyle.None; + Title = string.Empty; + X = -1; + Height = Dim.Fill(); + } + + // Extract root objects from PSObjects + var rootObjects = _applicationData.PSObjects?.Select(p => + { + if (p is PSObject pso) + return pso.BaseObject; + return p; + }).ToList() ?? []; + + var filterLabel = new Label + { + Text = "_Filter:", + X = 1 + }; + + var filterTextField = new TextField + { + Text = _applicationData.Filter ?? string.Empty, + X = Pos.Right(filterLabel) + 1, + Width = Dim.Fill(1), + CursorPosition = (_applicationData.Filter ?? string.Empty).Length + }; + + _filterErrorView = new Label + { + SchemeName = "Error", + X = Pos.Right(filterLabel) + 1, + Y = Pos.Top(filterLabel) + 1, + Width = Dim.Width(filterTextField), + Height = Dim.Auto(DimAutoStyle.Text) + }; + + _tree = new TreeView + { + Y = Pos.Bottom(_filterErrorView), + Width = Dim.Fill(), + Height = Dim.Fill(), + TreeBuilder = this, + AspectGetter = AspectGetter + }; + _tree.SelectionChanged += SelectionChanged; + + var regexFilter = new RegexTreeViewTextFilter(this, _tree) + { + Text = _applicationData.Filter ?? string.Empty + }; + _tree.Filter = regexFilter; + + if (rootObjects.Count > 0) + _tree.AddObjects(rootObjects); + else + _tree.AddObject("No Objects"); + + filterTextField.TextChanged += (sender, e) => OnFilterTextChanged(sender, e, regexFilter); + + var shortcuts = CreateShortcuts(rootObjects); + + _statusBar = new StatusBar(shortcuts) + { + Visible = !_applicationData.MinUI + }; + + if (!_applicationData.MinUI) + { + Add(filterLabel); + Add(filterTextField); + Add(_filterErrorView); + } + + Add(_tree); + Add(_statusBar); + } + + #endregion + + #region Event Handlers + + /// + /// Handles filter text changes and applies the regex filter. + /// + /// The text field that triggered the event. + /// The event arguments. + /// The regex filter to update. + private void OnFilterTextChanged(object? sender, EventArgs e, RegexTreeViewTextFilter regexFilter) + { + var textField = sender as TextField; + if (textField is null) return; + + // Test that the regex is valid before applying it + try + { + _ = new Regex(textField.Text ?? string.Empty, RegexOptions.IgnoreCase); + } + catch (RegexParseException ex) + { + _filterErrorView.Text = ex.Message; + return; + } + + _filterErrorView.Text = string.Empty; + regexFilter.Text = textField.Text ?? string.Empty; + } + + /// + /// Handles selection changes in the tree view and updates the status bar. + /// + /// The tree view that triggered the event. + /// The selection changed event arguments. + private void SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + var selectedValue = e.NewValue; + + if (selectedValue is CachedMemberResult cmr) + selectedValue = cmr.Value; + + if (selectedValue != null) + _selectedShortcut.Title = selectedValue.GetType().Name; + else + _selectedShortcut.Title = string.Empty; + + _statusBar.SetNeedsDraw(); + } + + #endregion + + #region Public Methods + + /// + /// Sets the regex error message displayed in the filter error view. + /// + /// The error message to display. + internal void SetRegexError(string error) + { + if (string.Equals(error, _filterErrorView.Text, StringComparison.Ordinal)) return; + _filterErrorView.Text = error; + } + + #endregion + + #region ITreeBuilder Implementation + + /// + /// Determines whether the specified object can be expanded to show children. + /// + /// The object to check for expansion capability. + /// if the object can be expanded; otherwise, . + public bool CanExpand(object toExpand) + { + if (toExpand is CachedMemberResult p) + return IsBasicType(p.Value); + + return IsBasicType(toExpand); + } + + /// + /// Gets the child objects for the specified parent object. + /// + /// The parent object to get children for. + /// An enumerable collection of child objects. + public IEnumerable GetChildren(object? forObject) + { + while (true) + { + if (forObject == null || !CanExpand(forObject)) + return []; + + if (forObject is CachedMemberResult p) + { + if (p.IsCollection) + return p.Elements ?? Enumerable.Empty(); + + forObject = p.Value; + continue; + } + + if (forObject is CachedMemberResultElement e) + { + forObject = e.Value; + continue; + } + + var children = new List(); + + foreach (var member in forObject.GetType().GetMembers(BindingFlags.Instance | BindingFlags.Public) + .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; + } + } + + #endregion + + #region Helper Methods + + /// + /// Gets the display text for an object in the tree view. + /// + /// The object to get display text for. + /// The display text for the object. + 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(); + } + + /// + /// Determines whether the specified object is a root object in the tree. + /// + /// The object to check. + /// if the object is a root object; otherwise, . + private bool IsRootObject(object o) => _tree.Objects.Contains(o); + + /// + /// Determines whether the specified value is a basic (non-primitive, non-string) type that can be expanded. + /// + /// The value to check. + /// if the value is a basic type; otherwise, . + private static bool IsBasicType(object? value) => + value != null && value is not string && !value.GetType().IsValueType; + + /// + /// Gets additional child objects for special types like DirectoryInfo. + /// + /// The object to get extra children for. + /// An enumerable collection of additional child objects. + private static IEnumerable GetExtraChildren(object forObject) + { + if (forObject is DirectoryInfo dir) + foreach (var c in dir.EnumerateFileSystemInfos()) + yield return c; + } + + /// + /// Creates the keyboard shortcuts for the status bar. + /// + /// The root objects being displayed. + /// A list of shortcuts for the status bar. + private List CreateShortcuts(List rootObjects) + { + var shortcuts = new List(); + + var elementDescription = "objects"; + var types = rootObjects.Select(o => o.GetType()).Distinct().ToArray(); + if (types.Length == 1) + elementDescription = types[0].Name; + + shortcuts.Add(new Shortcut(Key.Esc, "Close", () => Application.RequestStop())); + + var countShortcut = new Shortcut(Key.Empty, $"{rootObjects.Count} {elementDescription}", null); + _selectedShortcut = new Shortcut(Key.Empty, string.Empty, null); + shortcuts.Add(countShortcut); + shortcuts.Add(_selectedShortcut); + + if (_applicationData.Debug) + { + shortcuts.Add(new Shortcut(Key.Empty, $" v{_applicationData.ModuleVersion}", null)); + shortcuts.Add(new Shortcut(Key.Empty, + $"{Application.Driver} v{FileVersionInfo.GetVersionInfo(Assembly.GetAssembly(typeof(Application))!.Location).ProductVersion}", + null)); + } + + return shortcuts; + } + + #endregion +} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs index 989cab1..df275fc 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs @@ -2,461 +2,67 @@ // Licensed under the MIT License. using System; -using System.Collections; using System.Collections.Generic; -using System.Diagnostics; -using System.IO; using System.Linq; using System.Management.Automation; -using System.Reflection; -using System.Text.RegularExpressions; - -using OutGridView.Models; - -using Terminal.Gui; -using Terminal.Gui.Trees; - -namespace OutGridView.Cmdlet +using Microsoft.PowerShell.OutGridView.Models; +using Terminal.Gui.App; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Provides the main orchestration for the Show-ObjectTree cmdlet, managing the Terminal.Gui application lifecycle +/// and coordinating between the application data and the tree view window. +/// +/// +/// This class serves as a facade that initializes the Terminal.Gui framework, creates and runs the tree view window, +/// and handles cleanup operations. It delegates the actual UI rendering and user interaction to the class. +/// +internal sealed class ShowObjectView : IDisposable { - internal sealed class ShowObjectView : Window, ITreeBuilder + /// + /// Runs the Show-ObjectView Terminal.Gui Application with the specified configuration. + /// + /// The application configuration containing the PSObjects and display options. + /// + /// + /// This method initializes the Terminal.Gui framework, creates a instance, + /// and runs it until the user closes the window. The method handles the complete application lifecycle + /// including initialization, execution, and shutdown. + /// + /// + /// If is set, the NetDriver will be used instead of the + /// platform-specific driver (Windows Console API or Curses). + /// + /// + internal static void Run(ApplicationData applicationData) { - 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(), StringComparison.Ordinal)) - { - 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 static bool IsBasicType(object value) - { - return value != null && value is not string && !value.GetType().IsValueType; - } - - public IEnumerable GetChildren(object forObject) - { - if (forObject == null || !this.CanExpand(forObject)) - { - return Enumerable.Empty(); - } - - 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(BindingFlags.Instance | BindingFlags.Public).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 static IEnumerable GetExtraChildren(object forObject) - { - if (forObject is DirectoryInfo dir) - { - foreach (var c in dir.EnumerateFileSystemInfos()) - { - yield return c; - } - } - } + if (applicationData.UseNetDriver) + Application.ForceDriver = "NetDriver"; - internal static void Run(List objects, ApplicationData applicationData) + ShowObjectTreeWindow? window = null; + try { - // 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(); - } - } - - sealed 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}]"; - } + window = new ShowObjectTreeWindow(applicationData); + Application.Run(window); } - - sealed class CachedMemberResult + finally { - 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; - } + window?.Dispose(); + Application.Shutdown(); } - private sealed 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; - } - } - } + /// + /// Releases resources used by the . + /// + /// + /// Currently, there are no resources to dispose. This method is provided for future extensibility + /// and to follow the standard IDisposable pattern. + /// + public void Dispose() + { + // No resources to dispose currently } -} +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs index 6f3f643..3500651 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs @@ -1,198 +1,410 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Management.Automation; -using System.Management.Automation.Internal; - +using System.Management.Automation.Runspaces; +using System.Text.RegularExpressions; using Microsoft.PowerShell.Commands; +using Microsoft.PowerShell.OutGridView.Models; -using OutGridView.Models; +namespace Microsoft.PowerShell.ConsoleGuiTools; -namespace OutGridView.Cmdlet +/// +/// Provides methods to retrieve type information and convert PowerShell objects into data table structures for display +/// in the grid view using PowerShell's native formatting infrastructure. +/// +public class TypeGetter { - public class TypeGetter + private readonly Dictionary _formatCache = new(); + + /// + /// Regex pattern to match ANSI escape sequences. + /// + private static readonly Regex AnsiEscapeRegex = new(@"\x1b\[[0-9;]*m", RegexOptions.Compiled); + + /// + /// Strips ANSI escape sequences from a string. + /// + /// The string potentially containing ANSI codes. + /// The string with ANSI codes removed. + private static string StripAnsiCodes(string value) { - private PSCmdlet _cmdlet; + if (string.IsNullOrEmpty(value)) return value; + return AnsiEscapeRegex.Replace(value, string.Empty); + } - public TypeGetter(PSCmdlet cmdlet) - { - _cmdlet = cmdlet; - } - public FormatViewDefinition GetFormatViewDefinitionForObject(PSObject obj) + /// + /// Gets the format view definition for the specified type name, using a cache to avoid redundant lookups. + /// + /// The full type name to get the format view definition for. + /// The format view definition if found; otherwise, . + private FormatViewDefinition? GetFormatViewDefinitionForType(string typeName) + { + if (_formatCache.TryGetValue(typeName, out var cached)) return cached; + + try { - var typeName = obj.BaseObject.GetType().FullName; + // Use the current runspace to access format data from loaded modules + // This is critical for CIM instances (like Get-NetAdapter) which have format data + // defined in their respective modules' .ps1xml files + using var ps = System.Management.Automation.PowerShell.Create(RunspaceMode.CurrentRunspace); + ps.AddCommand("Get-FormatData").AddParameter("TypeName", typeName); - var types = _cmdlet.InvokeCommand.InvokeScript(@"Microsoft.PowerShell.Utility\Get-FormatData " + typeName).ToList(); + var results = ps.Invoke(); - //No custom type definitions found - try the PowerShell specific format data - if (types == null || types.Count == 0) + FormatViewDefinition? result = null; + if (results.Count > 0) { - types = _cmdlet.InvokeCommand - .InvokeScript(@"Microsoft.PowerShell.Utility\Get-FormatData -PowerShellVersion $PSVersionTable.PSVersion " + typeName).ToList(); - - if (types == null || types.Count == 0) - { - return null; - } + var extendedTypeDefinition = results[0].BaseObject as ExtendedTypeDefinition; + result = extendedTypeDefinition?.FormatViewDefinition.FirstOrDefault(v => v.Control is TableControl); } - var extendedTypeDefinition = types[0].BaseObject as ExtendedTypeDefinition; - - return extendedTypeDefinition.FormatViewDefinition[0]; + _formatCache[typeName] = result; + return result; + } + catch + { + // If we can't get format data (e.g., not running in a runspace context), + // cache null and continue with fallback logic + _formatCache[typeName] = null; + return null; } + } - public static DataTableRow CastObjectToDataTableRow(PSObject ps, List dataColumns, int objectIndex) + /// + /// Gets the format view definition for the specified PowerShell object. + /// + /// The PowerShell object to get the format view definition for. + /// The format view definition if found; otherwise, . + private FormatViewDefinition? GetFormatViewDefinitionForObject(PSObject obj) + { + // PSObject has a TypeNames collection that includes PowerShell-specific type names + // These are what the format system uses, not the .NET type name + // For example, Get-NetAdapter returns objects with TypeName like "Microsoft.Management.Infrastructure.CimInstance#ROOT/StandardCimv2/MSFT_NetAdapter" + + foreach (var typeName in obj.TypeNames) { - Dictionary valuePairs = new Dictionary(); + if (_formatCache.TryGetValue(typeName, out var cached)) + return cached; - foreach (var dataColumn in dataColumns) + var fvd = GetFormatViewDefinitionForType(typeName); + if (fvd != null) { - var expression = new PSPropertyExpression(ScriptBlock.Create(dataColumn.PropertyScriptAccessor)); + return fvd; + } + } - var result = expression.GetValues(ps).FirstOrDefault().Result; + // Fallback to base object type name + string? baseTypeName = obj.BaseObject.GetType().FullName; + if (baseTypeName is not null) + { + if (_formatCache.TryGetValue(baseTypeName, out var cached)) + return cached; - var stringValue = result?.ToString() ?? String.Empty; + return GetFormatViewDefinitionForType(baseTypeName); + } - var isDecimal = decimal.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat, out var decimalValue); + return null; + } - if (isDecimal) - { - valuePairs[dataColumn.ToString()] = new DecimalValue { DisplayValue = stringValue, SortValue = decimalValue }; - } - else + /// + /// Gets the default display property set (TableContent) for a PowerShell object. + /// This represents the subset of properties that PowerShell displays by default. + /// + /// The PowerShell object to examine. + /// A list of property names to display, or null if no default display set is defined. + private static List? GetDefaultDisplayPropertySet(PSObject obj) + { + try + { + // For CIM instances and other objects, PowerShell adds PSStandardMembers + // through the Extended Type System (ETS), not always as instance members + + // First check instance members (for objects with runtime-added members) + var standardMembers = obj.Members["PSStandardMembers"]?.Value as PSMemberSet; + var defaultDisplayProperty = standardMembers?.Members["DefaultDisplayPropertySet"]?.Value as PSPropertySet; + + if (defaultDisplayProperty?.ReferencedPropertyNames != null && defaultDisplayProperty.ReferencedPropertyNames.Count > 0) + { + return defaultDisplayProperty.ReferencedPropertyNames.ToList(); + } + + // Second, check PSObject.Properties for DefaultDisplayPropertySet + // Some objects have this defined through type adapters + var psStandardMembers = obj.Properties["PSStandardMembers"]; + if (psStandardMembers?.Value is PSMemberSet memberSet) + { + var displayPropSet = memberSet.Members["DefaultDisplayPropertySet"] as PSPropertySet; + if (displayPropSet?.ReferencedPropertyNames != null && displayPropSet.ReferencedPropertyNames.Count > 0) { - var stringDecorated = new StringDecorated(stringValue); - valuePairs[dataColumn.ToString()] = new StringValue { DisplayValue = stringDecorated.ToString(OutputRendering.PlainText) }; + return displayPropSet.ReferencedPropertyNames.ToList(); } } - - return new DataTableRow(valuePairs, objectIndex); } - - private static void SetTypesOnDataColumns(List dataTableRows, List dataTableColumns) + catch { - var dataRows = dataTableRows.Select(x => x.Values); + // If we can't get the default display property set, return null + } - foreach (var dataColumn in dataTableColumns) + return null; + } + + /// + /// Retrieves the column definitions for the specified PowerShell objects based on their format view definitions or properties. + /// + /// The list of PowerShell objects to analyze. + /// If true, returns all properties instead of just the default display properties. + /// A distinct list of data table columns. + private List GetDataColumnsForObject(List psObjects, bool allProperties = false) + { + var dataColumns = new List(); + + if (psObjects.Count == 0) return dataColumns; + + var firstObject = psObjects[0]; + + List labels; + List propertyAccessors; + + // If allProperties is requested, skip format view and default display property logic + if (allProperties) + { + if (PSObjectIsPrimitive(firstObject)) + { + // Handle primitive types + labels = [firstObject.BaseObject.GetType().Name]; + propertyAccessors = ["$_"]; + } + else { - dataColumn.StringType = typeof(decimal).FullName; + // Return all properties - use simple property access format for performance + // Filter out PS* metadata properties which are often expensive to compute + var properties = firstObject.Properties + .Where(p => p.IsGettable && !p.Name.StartsWith("PS")) + .ToList(); + + labels = properties.Select(p => p.Name).ToList(); + propertyAccessors = properties.Select(p => $"$_.\"{p.Name}\"").ToList(); } + } + else + { + // Priority order: + // 1. Format view definition (from .ps1xml files) - what cmdlets like Get-NetAdapter use + // 2. DefaultDisplayPropertySet (TableContent) - used by custom objects + // 3. All properties (fallback) + + var fvd = GetFormatViewDefinitionForObject(firstObject); + var defaultDisplayProps = GetDefaultDisplayPropertySet(firstObject); - //If every value in a column could be a decimal, assume that it is supposed to be a decimal - foreach (var dataRow in dataRows) + if (fvd?.Control is TableControl tableControl) { - foreach (var dataColumn in dataTableColumns) + // Use the table format definition (THIS IS WHAT GET-NETADAPTER USES) + var definedColumnLabels = tableControl.Headers.Select(h => h.Label).ToList(); + var displayEntries = tableControl.Rows[0].Columns.Select(c => c.DisplayEntry).ToArray(); + var propertyLabels = displayEntries.Select(de => de.Value).ToList(); + + // Use the TypeDefinition Label if available otherwise just use the property name as a label + labels = definedColumnLabels.Zip(propertyLabels, (definedLabel, propLabel) => { - if (!(dataRow[dataColumn.ToString()] is DecimalValue)) - { - dataColumn.StringType = typeof(string).FullName; - } - } + if (string.IsNullOrEmpty(definedLabel)) return propLabel; + return definedLabel; + }).ToList(); + + propertyAccessors = displayEntries.Select(de => + de.ValueType == DisplayEntryValueType.Property + ? $"$_.\"{de.Value}\"" + : de.Value // ScriptBlock + ).ToList(); + } + else if (defaultDisplayProps != null && defaultDisplayProps.Count > 0) + { + // Use the DefaultDisplayPropertySet (for custom objects) + labels = defaultDisplayProps; + propertyAccessors = defaultDisplayProps.Select(p => $"$_.\"{p}\"").ToList(); + } + else if (PSObjectIsPrimitive(firstObject)) + { + // Handle primitive types + labels = [firstObject.BaseObject.GetType().Name]; + propertyAccessors = ["$_"]; + } + else + { + // Fallback to all properties + labels = firstObject.Properties.Select(p => p.Name).ToList(); + propertyAccessors = firstObject.Properties.Select(p => $"$_.\"{p.Name}\"").ToList(); } } - private List GetDataColumnsForObject(List psObjects) + + for (int i = 0; i < labels.Count; i++) { - var dataColumns = new List(); + var column = new DataTableColumn(labels[i], propertyAccessors[i]); + dataColumns.Add(column); + } + return dataColumns.Distinct().ToList(); + } + /// + /// Types that are considered primitives to PowerShell but not to C#. + /// + private static readonly List ADDITIONAL_PRIMITIVE_TYPES = + [ + "System.String", + "System.Decimal", + "System.IntPtr", + "System.Security.SecureString", + "System.Numerics.BigInteger" + ]; + + /// + /// Determines whether the specified PowerShell object represents a primitive type. + /// + private static bool PSObjectIsPrimitive(PSObject ps) + { + var psBaseType = ps.BaseObject.GetType(); + return psBaseType.IsPrimitive || psBaseType.IsEnum || + ADDITIONAL_PRIMITIVE_TYPES.Contains(psBaseType.FullName!); + } - foreach (PSObject obj in psObjects) - { - var labels = new List(); + /// + /// Converts a PowerShell object to a data table row using PSPropertyExpression to evaluate property accessors. + /// + public static DataTableRow CastObjectToDataTableRow(PSObject psObject, List dataTableColumns, int objectIndex) + { + var valuePairs = new Dictionary(); - FormatViewDefinition fvd = GetFormatViewDefinitionForObject(obj); + foreach (var column in dataTableColumns) + { + object? result = null; - var propertyAccessors = new List(); + try + { + // PSPropertyExpression constructor takes a ScriptBlock for script expressions + // For simple properties, we need to extract just the property name + var accessor = column.PropertyScriptAccessor; - if (fvd == null) + if (accessor.StartsWith("$_.\"") && accessor.EndsWith("\"")) { - if (PSObjectIsPrimitive(obj)) - { - labels = new List { obj.BaseObject.GetType().Name }; - propertyAccessors = new List { "$_" }; - } - else + // Extract property name from "$_."PropertyName"" format + var propertyName = accessor.Substring(4, accessor.Length - 5); + var property = psObject.Properties[propertyName]; + result = property?.Value; + + // Unwrap PSObject if needed to get the base value + if (result is PSObject psObjResult) { - labels = obj.Properties.Select(x => x.Name).ToList(); - propertyAccessors = obj.Properties.Select(x => $"$_.\"{x.Name}\"").ToList(); + result = psObjResult.BaseObject; } } + else if (accessor == "$_") + { + // The whole object + result = psObject.BaseObject; + } else { - var tableControl = fvd.Control as TableControl; + // It's a script block - create and invoke it + var scriptBlock = ScriptBlock.Create(accessor); + var results = scriptBlock.InvokeWithContext(null, new List + { + new PSVariable("_", psObject) + }); + result = results.FirstOrDefault(); - var definedColumnLabels = tableControl.Headers.Select(x => x.Label); + // Unwrap PSObject if needed + if (result is PSObject psScriptResult) + { + result = psScriptResult.BaseObject; + } + } + } + catch (Exception ex) + { + // If evaluation fails, use null + result = null; + } - var displayEntries = tableControl.Rows[0].Columns.Select(x => x.DisplayEntry); + // Convert to string and strip ANSI codes + var displayValue = result?.ToString() ?? string.Empty; + displayValue = StripAnsiCodes(displayValue); - var propertyLabels = displayEntries.Select(x => x.Value); + // Determine if this is a numeric value for sorting + var isNumeric = result is decimal or int or long or short or byte or double or float or uint or ulong or ushort or sbyte; - //Use the TypeDefinition Label if availble otherwise just use the property name as a label - labels = definedColumnLabels.Zip(propertyLabels, (definedColumnLabel, propertyLabel) => - { - if (String.IsNullOrEmpty(definedColumnLabel)) - { - return propertyLabel; - } - return definedColumnLabel; - }).ToList(); - - - propertyAccessors = displayEntries.Select(x => - { - //If it's a propety access directly - if (x.ValueType == DisplayEntryValueType.Property) - { - return $"$_.\"{x.Value}\""; - } - //Otherwise return access script - return x.Value; - }).ToList(); - } + var columnKey = column.ToString(); - for (var i = 0; i < labels.Count; i++) + if (isNumeric) + { + var decimalValue = Convert.ToDecimal(result, CultureInfo.InvariantCulture); + valuePairs[columnKey] = new DecimalValue { - dataColumns.Add(new DataTableColumn(labels[i], propertyAccessors[i])); - } + DisplayValue = displayValue, + SortValue = decimalValue + }; + } + else + { + valuePairs[columnKey] = new StringValue + { + DisplayValue = displayValue, + RawValue = result + }; } - return dataColumns.Distinct().ToList(); } - public DataTable CastObjectsToTableView(List psObjects) - { - List objectFormats = psObjects.Select(GetFormatViewDefinitionForObject).ToList(); + return new DataTableRow(valuePairs, objectIndex); + } - var dataTableColumns = GetDataColumnsForObject(psObjects); + /// + /// Sets the data type on each column based on the values in the data rows. + /// + private static void SetTypesOnDataColumns(List dataTableRows, List dataTableColumns) + { + var dataRows = dataTableRows.Select(x => x.Values); + + foreach (var dataColumn in dataTableColumns) + dataColumn.StringType = typeof(decimal).FullName; - List dataTableRows = new List(); - for (var i = 0; i < objectFormats.Count; i++) + // If every value in a column could be a decimal, assume that it is supposed to be a decimal + foreach (var dataRow in dataRows) + { + foreach (var dataColumn in dataTableColumns) { - var dataTableRow = CastObjectToDataTableRow(psObjects[i], dataTableColumns, i); - dataTableRows.Add(dataTableRow); + if (dataRow[dataColumn.ToString()] is not DecimalValue) + dataColumn.StringType = typeof(string).FullName; } + } + } - SetTypesOnDataColumns(dataTableRows, dataTableColumns); - - return new DataTable(dataTableColumns, dataTableRows); + /// + /// Converts a list of PowerShell objects into a data table structure suitable for display in the grid view. + /// + /// The list of PowerShell objects to convert. + /// If true, includes all properties instead of just the default display properties. + public static DataTable CastObjectsToTableView(List psObjects, bool allProperties = false) + { + if (psObjects.Count == 0) + { + return new DataTable([], []); } + // Get the columns using format view definitions + var typeGetter = new TypeGetter(); + List dataTableColumns = typeGetter.GetDataColumnsForObject(psObjects, allProperties).ToList(); - //Types that are condisidered primitives to PowerShell but not C# - private readonly static List additionalPrimitiveTypes = new List { "System.String", - "System.Decimal", - "System.IntPtr", - "System.Security.SecureString", - "System.Numerics.BigInteger" - }; - private static bool PSObjectIsPrimitive(PSObject ps) + // Convert each object to a row + var dataTableRows = new List(); + for (var i = 0; i < psObjects.Count; i++) { - var psBaseType = ps.BaseObject.GetType(); - - return psBaseType.IsPrimitive || psBaseType.IsEnum || additionalPrimitiveTypes.Contains(psBaseType.FullName); + var dataTableRow = CastObjectToDataTableRow(psObjects[i], dataTableColumns, i); + dataTableRows.Add(dataTableRow); } + + SetTypesOnDataColumns(dataTableRows, dataTableColumns); + + return new DataTable(dataTableColumns, dataTableRows); } -} +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs b/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs index 84959f5..e4e18e8 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs @@ -1,24 +1,63 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System; using System.Collections.Generic; -namespace OutGridView.Models +namespace Microsoft.PowerShell.OutGridView.Models; + +/// +/// Represents the application configuration and state data for the Out-ConsoleGridView component. +/// +public class ApplicationData { - public class ApplicationData - { - public string Title { get; set; } - public OutputModeOption OutputMode { get; set; } - public bool PassThru { get; set; } - public string Filter { get; set; } - public bool MinUI { get; set; } - public DataTable DataTable { get; set; } - - public bool UseNetDriver { get; set; } - public bool Verbose { get; set; } - public bool Debug { get; set; } - - public string ModuleVersion { get; set; } - } -} + /// + /// Gets or sets the PowerShell objects to display. + /// + public List? PSObjects { get; set; } + + /// + /// Gets or sets the output mode that determines how items can be selected and returned. + /// + public OutputModeOption OutputMode { get; set; } + + /// + /// Gets or sets the title displayed in the Out-GridView window. + /// + public string? Title { get; set; } + + /// + /// Gets or sets the filter text to apply to the data. + /// + public string? Filter { get; set; } + + /// + /// Gets or sets a value indicating whether to use minimal UI mode. + /// + public bool MinUI { get; set; } + + /// + /// Gets or sets a value indicating whether to use the .NET driver for rendering. + /// + public bool UseNetDriver { get; set; } + + /// + /// Gets or sets a value indicating whether all properties should be displayed. If false, only default display + /// properties are shown. + /// + public bool AllProperties { get; set; } + + /// + /// Gets or sets a value indicating whether verbose output is enabled. + /// + public bool Verbose { get; set; } + + /// + /// Gets or sets a value indicating whether debug output is enabled. + /// + public bool Debug { get; set; } + + /// + /// Gets or sets the version of the module. + /// + public string? ModuleVersion { get; set; } +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.OutGridView.Models/DataTable.cs b/src/Microsoft.PowerShell.OutGridView.Models/DataTable.cs index 950a7e5..3260700 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/DataTable.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/DataTable.cs @@ -2,18 +2,32 @@ // Licensed under the MIT License. using System.Collections.Generic; -using System.Collections.ObjectModel; -namespace OutGridView.Models + +namespace Microsoft.PowerShell.OutGridView.Models; + +/// +/// Represents a data table containing rows and columns for display in the grid view. +/// +public class DataTable { - public class DataTable - { - public List Data { get; set; } - public List DataColumns { get; set; } - public DataTable(List columns, List data) - { - DataColumns = columns; + /// + /// Gets or sets the list of data rows in the table. + /// + public List Data { get; set; } - Data = data; - } + /// + /// Gets or sets the list of column definitions for the table. + /// + public List DataColumns { get; set; } + + /// + /// Initializes a new instance of the class with the specified columns and data. + /// + /// The list of column definitions for the table. + /// The list of data rows for the table. + public DataTable(List columns, List data) + { + DataColumns = columns; + Data = data; } -} +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.OutGridView.Models/DataTableColumn.cs b/src/Microsoft.PowerShell.OutGridView.Models/DataTableColumn.cs index 0390dc4..a2e70a3 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/DataTableColumn.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/DataTableColumn.cs @@ -2,39 +2,114 @@ // Licensed under the MIT License. using System; -using Newtonsoft.Json; using System.Text; +using Newtonsoft.Json; + +// TODO: switch to System.Text.Json -namespace OutGridView.Models +namespace Microsoft.PowerShell.OutGridView.Models; + +/// +/// Represents a column in a data table with metadata about its label, type, and property accessor. +/// +/// The display label for the column. +/// The script accessor used to retrieve the property value. +public class DataTableColumn(string label, string propertyScriptAccessor) { - public class DataTableColumn + /// + /// Gets the runtime type of the column based on the property. + /// + [JsonIgnore] + public Type? Type => System.Type.GetType(StringType!); + + /// + /// Gets the display label for the column. + /// + public string Label { get; } = label; + + /// + /// Gets or sets the serializable string representation of the column's type. + /// + public string? StringType { get; set; } + + /// + /// Gets or sets the format string used to format values in this column. + /// This follows PowerShell/. NET composite formatting conventions (e.g., "N0" for numbers, "G" for DateTime). + /// + public string? FormatString { get; set; } + + /// + /// Gets the script accessor used to retrieve the property value for this column. + /// + public string PropertyScriptAccessor { get; } = propertyScriptAccessor; + + /// + /// Formats a value according to this column's format specification. + /// + /// The value to format. + /// A formatted string representation of the value. + public string FormatValue(object? value) { - [JsonIgnore] - public Type Type => Type.GetType(StringType); - public string Label { get; set; } - //Serializable Version of Type - public string StringType { get; set; } - public string PropertyScriptAccessor { get; set; } - public DataTableColumn(string label, string propertyScriptAccessor) - { - Label = label; - PropertyScriptAccessor = propertyScriptAccessor; - } + if (value == null) return string.Empty; - //Distinct column defined by Label, Prop Accessor - public override bool Equals(object obj) + // If we have a format string, try to use it + if (!string.IsNullOrEmpty(FormatString) && value is IFormattable formattable) { - DataTableColumn b = obj as DataTableColumn; - return b.Label == Label && b.PropertyScriptAccessor == PropertyScriptAccessor; + try + { + return formattable.ToString(FormatString, System.Globalization.CultureInfo.CurrentCulture); + } + catch + { + // Fall through to default formatting if format string is invalid + } } - public override int GetHashCode() + + // If FormatString is explicitly null, use simple ToString for most types + // (this prevents unwanted formatting of identifier integers like ProcessId) + if (FormatString == null) { - return Label.GetHashCode() + PropertyScriptAccessor.GetHashCode(); + return value.ToString() ?? string.Empty; } - public override string ToString() + + // Default formatting based on type (only when FormatString is empty but not null) + return value switch { - //Needs to be encoded to embed safely in xaml - return Convert.ToBase64String(Encoding.UTF8.GetBytes(Label + PropertyScriptAccessor)); - } + DateTime dt => dt.ToString("G", System.Globalization.CultureInfo.CurrentCulture), + decimal d => d.ToString("N0", System.Globalization.CultureInfo.CurrentCulture), + double db => db.ToString("N2", System.Globalization.CultureInfo.CurrentCulture), + float f => f.ToString("N2", System.Globalization.CultureInfo.CurrentCulture), + int or long or short or byte => string.Format(System.Globalization.CultureInfo.CurrentCulture, "{0:N0}", value), + _ => value.ToString() ?? string.Empty + }; } -} + + /// + /// Determines whether the specified object is equal to the current column. + /// Two columns are considered equal if they have the same label and property script accessor. + /// + /// The object to compare with the current column. + /// + /// if the specified object is equal to the current column; otherwise, + /// . + /// + public override bool Equals(object? obj) + { + var b = obj as DataTableColumn; + return b?.Label == Label && b.PropertyScriptAccessor == PropertyScriptAccessor; + } + + /// + /// Returns the hash code for this column based on its label and property script accessor. + /// + /// A hash code for the current column. + public override int GetHashCode() => Label.GetHashCode() + PropertyScriptAccessor.GetHashCode(); + + /// + /// Returns a Base64-encoded string representation of the column for safe embedding in XAML. + /// + /// A Base64-encoded string containing the label and property script accessor. + public override string ToString() => + // Needs to be encoded to embed safely in XAML + Convert.ToBase64String(Encoding.UTF8.GetBytes(Label + PropertyScriptAccessor)); +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.OutGridView.Models/DataTableRow.cs b/src/Microsoft.PowerShell.OutGridView.Models/DataTableRow.cs index 3888691..5eb958d 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/DataTableRow.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/DataTableRow.cs @@ -4,44 +4,118 @@ using System; using System.Collections.Generic; -namespace OutGridView.Models +namespace Microsoft.PowerShell.OutGridView.Models; + +/// +/// Represents a value that can be displayed and compared in a data table. +/// +public interface IValue : IComparable { - public interface IValue : IComparable - { - string DisplayValue { get; set; } - } - public class DecimalValue : IValue - { - public string DisplayValue { get; set; } - public decimal SortValue { get; set; } - - public int CompareTo(object obj) - { - DecimalValue otherDecimalValue = obj as DecimalValue; - if (otherDecimalValue == null) return 1; - return Decimal.Compare(SortValue, otherDecimalValue.SortValue); - } - } - public class StringValue : IValue - { - public string DisplayValue { get; set; } - public int CompareTo(object obj) - { - StringValue otherStringValue = obj as StringValue; - if (otherStringValue == null) return 1; - return DisplayValue.CompareTo(otherStringValue.DisplayValue); - } - } - public class DataTableRow + /// + /// Gets or sets the string representation of the value for display purposes. + /// + string DisplayValue { get; set; } + + /// + /// Gets the original object value before formatting. + /// + object? OriginalValue { get; } +} + +/// +/// Represents a decimal value in a data table with support for numeric sorting. +/// +public class DecimalValue : IValue +{ + /// + /// Gets or sets the string representation of the decimal value for display purposes. + /// + public required string DisplayValue { get; set; } + + /// + /// Gets or sets the decimal value used for sorting. + /// + public decimal SortValue { get; set; } + + /// + /// Gets the original decimal value. + /// + public object? OriginalValue => SortValue; + + /// + /// Compares the current instance with another object of the same type. + /// + /// An object to compare with this instance. + /// + /// A value that indicates the relative order of the objects being compared. + /// Less than zero if this instance precedes , + /// zero if they are equal, or greater than zero if this instance follows . + /// Returns 1 if is not a . + /// + public int CompareTo(object? obj) => obj is not DecimalValue otherDecimalValue + ? 1 + : decimal.Compare(SortValue, otherDecimalValue.SortValue); +} + +/// +/// Represents a string value in a data table with support for string sorting. +/// +public class StringValue : IValue +{ + /// + /// Gets or sets the string value for display and sorting purposes. + /// + public required string DisplayValue { get; set; } + + /// + /// Gets or sets the original object value before conversion to string. + /// + public object? RawValue { get; set; } + + /// + /// Gets the original object value. + /// + public object? OriginalValue => RawValue ?? DisplayValue; + + /// + /// Compares the current instance with another object of the same type. + /// + /// An object to compare with this instance. + /// + /// A value that indicates the relative order of the objects being compared. + /// Less than zero if this instance precedes , + /// zero if they are equal, or greater than zero if this instance follows . + /// Returns 1 if is not a . + /// + public int CompareTo(object? obj) => obj is not StringValue otherStringValue + ? 1 + : string.Compare(DisplayValue, otherStringValue.DisplayValue, StringComparison.Ordinal); +} + +/// +/// Represents a single row in a data table with values mapped to column identifiers. +/// +public class DataTableRow +{ + /// + /// Gets or sets the dictionary of values for this row, keyed by the column identifier. + /// The key is the data column hash code serialized as a string for JSON compatibility. + /// + public Dictionary Values { get; set; } + + /// + /// Gets or sets the original index of the object in the source collection before any transformations. + /// + public int OriginalObjectIndex { get; set; } + + /// + /// Initializes a new instance of the class with the specified values and original index. + /// + /// The dictionary of values for this row, keyed by column identifier. + /// The original index of the object in the source collection. + public DataTableRow(Dictionary data, int originalObjectIndex) { - //key is datacolumn hash code - //have to do it this way because JSON can't serialize objects as keys - public Dictionary Values { get; set; } - public int OriginalObjectIndex { get; set; } - public DataTableRow(Dictionary data, int originalObjectIndex) - { - Values = data; - OriginalObjectIndex = originalObjectIndex; - } + Values = data; + OriginalObjectIndex = originalObjectIndex; } -} +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.OutGridView.Models/Microsoft.PowerShell.OutGridView.Models.csproj b/src/Microsoft.PowerShell.OutGridView.Models/Microsoft.PowerShell.OutGridView.Models.csproj index a4f10f5..71d447e 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/Microsoft.PowerShell.OutGridView.Models.csproj +++ b/src/Microsoft.PowerShell.OutGridView.Models/Microsoft.PowerShell.OutGridView.Models.csproj @@ -2,8 +2,11 @@ net8.0 + latest + enable + diff --git a/src/Microsoft.PowerShell.OutGridView.Models/OutputModeOptions.cs b/src/Microsoft.PowerShell.OutGridView.Models/OutputModeOptions.cs index 3170c98..bc499b8 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/OutputModeOptions.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/OutputModeOptions.cs @@ -1,23 +1,24 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -namespace OutGridView.Models +namespace Microsoft.PowerShell.OutGridView.Models; + +public enum OutputModeOption { - public enum OutputModeOption - { - /// - /// None is the default and it means OK and Cancel will not be present - /// and no objects will be written to the pipeline. - /// The selectionMode of the actual list will still be multiple. - /// - None, - /// - /// Allow selection of one single item to be written to the pipeline. - /// - Single, - /// - ///Allow select of multiple items to be written to the pipeline. - /// - Multiple - } -} + /// + /// None is the default, and it means OK and Cancel will not be present + /// and no objects will be written to the pipeline. + /// The selectionMode of the actual list will still be multiple. + /// + None, + + /// + /// Allow selection of one single item to be written to the pipeline. + /// + Single, + + /// + /// Allow select of multiple items to be written to the pipeline. + /// + Multiple +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.OutGridView.Models/Serializers.cs b/src/Microsoft.PowerShell.OutGridView.Models/Serializers.cs index 6165c23..f5f8a53 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/Serializers.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/Serializers.cs @@ -1,32 +1,33 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using Newtonsoft.Json; using System; using System.Text; -using System.Collections.Generic; -//TODO: swich to JSON.NET +using Newtonsoft.Json; + +// TODO: switch to JSON.NET +// BUGBUG: This appears to be unused code. Consider removing it. -namespace OutGridView.Models +namespace Microsoft.PowerShell.OutGridView.Models { public class Serializers { - private readonly static JsonSerializerSettings jsonSerializerSettings = new JsonSerializerSettings() + private static readonly JsonSerializerSettings JSON_SERIALIZER_SETTINGS = new JsonSerializerSettings() { TypeNameHandling = TypeNameHandling.All }; public static string ObjectToJson(T obj) { - var jsonString = JsonConvert.SerializeObject(obj, jsonSerializerSettings); + var jsonString = JsonConvert.SerializeObject(obj, JSON_SERIALIZER_SETTINGS); return ToBase64String(jsonString); } - public static T ObjectFromJson(string base64Json) + public static T? ObjectFromJson(string base64Json) { var jsonString = FromBase64String(base64Json); - return JsonConvert.DeserializeObject(jsonString, jsonSerializerSettings); + return JsonConvert.DeserializeObject(jsonString, JSON_SERIALIZER_SETTINGS); }