From 63ff5a6fde1f8515d58cc7f8bc597d946ad78d12 Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 12 Nov 2025 17:39:44 -0700 Subject: [PATCH 01/19] WIP: Builds but doesn't run WIP: Builds but doesn't run --- .gitignore | 53 +- .vscode/settings.json | 3 + ConsoleGuiTools.build.ps1 | 19 +- Directory.Packages.props | 3 +- GraphicalTools.sln | 36 + .../ConsoleGui.cs | 255 ++++--- .../GridViewDataSource.cs | 116 ++- ...icrosoft.PowerShell.ConsoleGuiTools.csproj | 3 +- .../ShowObjectView.cs | 700 +++++++++--------- ...osoft.PowerShell.OutGridView.Models.csproj | 2 + 10 files changed, 649 insertions(+), 541 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 GraphicalTools.sln 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..d30fa55 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,6 @@ - - + diff --git a/GraphicalTools.sln b/GraphicalTools.sln new file mode 100644 index 0000000..62b7314 --- /dev/null +++ b/GraphicalTools.sln @@ -0,0 +1,36 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +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/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs index e4385e1..3977743 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs @@ -10,44 +10,52 @@ using OutGridView.Models; -using Terminal.Gui; +using Terminal.Gui.App; +using Terminal.Gui.Configuration; +using Terminal.Gui.Drawing; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; -namespace OutGridView.Cmdlet +namespace OutGridView.Cmdlet; + +internal sealed class ConsoleGui : IDisposable { - 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 propagated back to _inputSource. + private GridViewDataSource? _listViewSource; + private ApplicationData? _applicationData; + private GridViewDetails? _gridViewDetails; + + public HashSet Start(ApplicationData applicationData) { - 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; + // In Terminal.Gui v2, Application.Init() no longer accepts a driver parameter. + // Instead, use Application.ForceDriver to specify the driver. + if (_applicationData.UseNetDriver) { - _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. + Application.ForceDriver = "NetDriver"; + } + 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 }; @@ -164,94 +172,100 @@ private void Close() { _cancelled = true; Application.RequestStop(); - } + } - private Window CreateTopLevelWindow() + private Window CreateTopLevelWindow() + { + // Creates the top-level window to show + var win = new Window { - // 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) - }; + Title = _applicationData!.Title ?? "Out-ConsoleGridView", + X = _applicationData.MinUI ? -1 : 0, + Y = _applicationData.MinUI ? -1 : 0, - if (_applicationData.MinUI) - { - win.Border.BorderStyle = BorderStyle.None; - } + // 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) + }; - Application.Top.Add(win); - return win; + if (_applicationData.MinUI) + { + win.BorderStyle = LineStyle.None; } + Application.Top!.Add(win); + return win; + } + private void AddStatusBar(bool visible) { - var statusItems = new List(); - if (_applicationData.OutputMode != OutputModeOption.None) + var shortcuts = new List(); + if (_applicationData!.OutputMode != OutputModeOption.None) { - // Use Key.Unknown for SPACE with no delegate because ListView already + // Use Key.Empty for SPACE with no delegate because ListView already // handles SPACE - statusItems.Add(new StatusItem(Key.Unknown, "~SPACE~ Select Item", null)); + shortcuts.Add(new Shortcut(Key.Empty, "~SPACE~ Select Item", null)); } if (_applicationData.OutputMode == OutputModeOption.Multiple) { - statusItems.Add(new StatusItem(Key.A | Key.CtrlMask, "~CTRL-A~ Select All", () => + shortcuts.Add(new Shortcut(Key.A.WithCtrl, "~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(); + var gvds = _listView!.Source as GridViewDataSource; + gvds!.GridViewRowList.ForEach(i => i.IsMarked = true); + _listView.SetNeedsDraw(); })); // Ctrl-D is commonly used in GUIs for select-none - statusItems.Add(new StatusItem(Key.D | Key.CtrlMask, "~CTRL-D~ Select None", () => + shortcuts.Add(new Shortcut(Key.D.WithCtrl, "~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(); + var gvds = _listView!.Source as GridViewDataSource; + gvds!.GridViewRowList.ForEach(i => i.IsMarked = false); + _listView.SetNeedsDraw(); })); } if (_applicationData.OutputMode != OutputModeOption.None) { - statusItems.Add(new StatusItem(Key.Enter, "~ENTER~ Accept", () => + shortcuts.Add(new Shortcut(Key.Enter, "~ENTER~ Accept", () => { - if (Application.Top.MostFocused == _listView) + 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) + _inputSource!.GridViewRowList.Find(i => i.IsMarked) == null) { - _listView.MarkUnmarkRow(); + // Toggle the mark on the currently selected item + 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) + else if (Application.Top?.MostFocused == _filterField) { - _listView.SetFocus(); + _listView!.SetFocus(); } })); } - statusItems.Add(new StatusItem(Key.Esc, "~ESC~ Close", () => Close())); + shortcuts.Add(new Shortcut(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)); + 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)); } - var statusBar = new StatusBar(statusItems.ToArray()); + var statusBar = new StatusBar(shortcuts); statusBar.Visible = visible; - Application.Top.Add(statusBar); + Application.Top!.Add(statusBar); } private void CalculateColumnWidths(List gridHeaders) @@ -304,14 +318,16 @@ private void CalculateColumnWidths(List gridHeaders) private void AddFilter(Window win) { - _filterLabel = new Label(FILTER_LABEL) + _filterLabel = new Label { + Text = FILTER_LABEL, X = MARGIN_LEFT, Y = 0 }; - _filterField = new TextField(_applicationData.Filter ?? string.Empty) + _filterField = new TextField { + Text = _applicationData!.Filter ?? string.Empty, X = Pos.Right(_filterLabel) + 1, Y = Pos.Top(_filterLabel), CanFocus = true, @@ -322,35 +338,32 @@ private void AddFilter(Window win) // 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); + _filterField.KeyBindings.Remove(Key.A.WithCtrl); + _filterField.KeyBindings.Remove(Key.D.WithCtrl); - var filterErrorLabel = new Label(string.Empty) + var filterErrorLabel = new Label { + Text = string.Empty, X = Pos.Right(_filterLabel) + 1, Y = Pos.Top(_filterLabel) + 1, - ColorScheme = Colors.Base, - Width = Dim.Fill() - _filterLabel.Text.Length + Width = Dim.Fill() - _filterLabel.Text!.Length }; - _filterField.TextChanged += (str) => + _filterField.TextChanged += (sender, e) => { - // str is the OLD value - string filterText = _filterField.Text?.ToString(); + string? filterText = _filterField.Text?.ToString(); try { filterErrorLabel.Text = " "; - filterErrorLabel.ColorScheme = Colors.Base; - filterErrorLabel.Redraw(filterErrorLabel.Bounds); - _applicationData.Filter = filterText; + filterErrorLabel.SetNeedsDraw(); + _applicationData!.Filter = filterText; ApplyFilter(); } catch (Exception ex) { filterErrorLabel.Text = ex.Message; - filterErrorLabel.ColorScheme = Colors.Error; - filterErrorLabel.Redraw(filterErrorLabel.Bounds); + filterErrorLabel.SchemeName = "Error"; } }; @@ -362,12 +375,15 @@ private void AddFilter(Window win) private void AddHeaders(Window win, List gridHeaders) { - var header = new Label(GridViewHelpers.GetPaddedString( - gridHeaders, - _gridViewDetails.ListViewOffset, - _gridViewDetails.ListViewColumnWidths)); - header.X = 0; - if (_applicationData.MinUI) + var header = new Label + { + Text = GridViewHelpers.GetPaddedString( + gridHeaders, + _gridViewDetails!.ListViewOffset, + _gridViewDetails.ListViewColumnWidths), + X = 0 + }; + if (_applicationData!.MinUI) { header.Y = 0; } @@ -379,7 +395,7 @@ private void AddHeaders(Window win, List gridHeaders) // 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) + foreach (char c in header.Text!) { if (c.Equals(' ')) { @@ -394,8 +410,9 @@ private void AddHeaders(Window win, List gridHeaders) if (!_applicationData.MinUI) { - var headerLine = new Label(headerLineText.ToString()) + var headerLine = new Label { + Text = headerLineText.ToString(), X = 0, Y = Pos.Bottom(header) }; @@ -405,11 +422,14 @@ private void AddHeaders(Window win, List gridHeaders) private void AddListView(Window win) { - _listView = new ListView(_inputSource); - _listView.X = MARGIN_LEFT; - if (!_applicationData.MinUI) + _listView = new ListView + { + Source = _inputSource, + X = MARGIN_LEFT + }; + if (!_applicationData!.MinUI) { - _listView.Y = Pos.Bottom(_filterLabel) + 3; // 1 for space, 1 for header, 1 for header underline + _listView.Y = Pos.Bottom(_filterLabel!) + 3; // 1 for space, 1 for header, 1 for header underline } else { @@ -419,25 +439,26 @@ private void AddListView(Window win) _listView.Height = Dim.Fill(); _listView.AllowsMarking = _applicationData.OutputMode != OutputModeOption.None; _listView.AllowsMultipleSelection = _applicationData.OutputMode == OutputModeOption.Multiple; - _listView.AddKeyBinding(Key.Space, Command.ToggleChecked, Command.LineDown); + + // In Terminal.Gui v2, key bindings work differently + // The ListView already handles Space for toggling marks by default win.Add(_listView); } - public void Dispose() + public void Dispose() + { + if (!Console.IsInputRedirected) { - 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"); - } + // 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..04c0b99 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs @@ -4,80 +4,72 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Collections.Specialized; +using System.Text; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.Text; +using Terminal.Gui.Views; -using NStack; +namespace OutGridView.Cmdlet; -using Terminal.Gui; - -namespace OutGridView.Cmdlet +internal sealed class GridViewDataSource : IListDataSource, IDisposable { - internal sealed class GridViewDataSource : IListDataSource - { - public List GridViewRowList { get; set; } - - public int Count => GridViewRowList.Count; + public List GridViewRowList { get; set; } - public GridViewDataSource(List itemList) - { - GridViewRowList = itemList; - } + public int Count => GridViewRowList.Count; + + public int Length => GridViewRowList.Count; + + public bool SuspendCollectionChangedEvent { get; set; } + + public event NotifyCollectionChangedEventHandler? CollectionChanged; - public int Length { get; } + public GridViewDataSource(List itemList) + { + 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); - } + public void Render(ListView listView, bool selected, int item, int col, int line, int width, int start = 0) + { + listView.Move(col, line); - public bool IsMarked(int item) => GridViewRowList[item].IsMarked; + var driver = Application.Driver; + var row = GridViewRowList[item]; + driver!.AddStr(row.DisplayString); + + } - 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 bool IsMarked(int item) => GridViewRowList[item].IsMarked; - public sealed class RowMarkedEventArgs : EventArgs + 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; } - - } + Row = GridViewRowList[item], + OldValue = oldValue + }; + MarkChanged?.Invoke(this, args); + } - public event EventHandler MarkChanged; + public sealed class RowMarkedEventArgs : EventArgs + { + public required GridViewRow Row { get; set; } + public bool OldValue { get; set; } + } - public IList ToList() - { - return GridViewRowList; - } + public event EventHandler? MarkChanged; - // 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++; - } - } + public IList ToList() + { + return GridViewRowList; } + + public void Dispose() + { + // No resources to dispose currently + } + } diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj b/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj index ed25247..ec7ba70 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 + latest + enable @@ -15,7 +17,6 @@ - diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs index 989cab1..133dda1 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs @@ -13,450 +13,458 @@ using OutGridView.Models; -using Terminal.Gui; -using Terminal.Gui.Trees; +using Terminal.Gui.App; +using Terminal.Gui.Drawing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; +using Terminal.Gui.Input; -namespace OutGridView.Cmdlet +namespace OutGridView.Cmdlet; + +internal sealed class ShowObjectView : Window, ITreeBuilder { - internal sealed class ShowObjectView : Window, ITreeBuilder + private readonly TreeView tree; + private readonly RegexTreeViewTextFilter filter; + private readonly Label filterErrorLabel; + + public bool SupportsCanExpand => true; + private Shortcut? selectedStatusBarItem; + private StatusBar? statusBar; + + public ShowObjectView(List rootObjects, ApplicationData applicationData) { - private readonly TreeView tree; - private readonly RegexTreeViewTextFilter filter; - private readonly Label filterErrorLabel; + Title = applicationData.Title ?? "Show Object Tree"; + Width = Dim.Fill(); + Height = Dim.Fill(1); + Modal = false; - public bool SupportsCanExpand => true; - private StatusItem selectedStatusBarItem; - private StatusBar statusBar; - public ShowObjectView(List rootObjects, ApplicationData applicationData) + if (applicationData.MinUI) { - Title = applicationData.Title; - Width = Dim.Fill(); - Height = Dim.Fill(1); - Modal = false; + BorderStyle = LineStyle.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; + + // In Terminal.Gui v2, the default keybindings are different + // No need to clear ExpandAll keybinding + + 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"); + } + + var shortcuts = new List(); - if (applicationData.MinUI) - { - Border.BorderStyle = BorderStyle.None; - Title = string.Empty; - X = -1; - Height = Dim.Fill(); - } + string elementDescription = "objects"; - 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; + var types = rootObjects.Select(o => o.GetType()).Distinct().ToArray(); + if (types.Length == 1) + { + elementDescription = types[0].Name; + } - tree.ClearKeybinding(Command.ExpandAll); + var lblFilter = new Label + { + Text = "Filter:", + X = 1, + }; + var tbFilter = new TextField + { + Text = applicationData.Filter ?? string.Empty, + X = Pos.Right(lblFilter), + Width = Dim.Fill(1) + }; + tbFilter.CursorPosition = tbFilter.Text.Length; - this.filter = new RegexTreeViewTextFilter(this, tree); - this.filter.Text = applicationData.Filter ?? string.Empty; - tree.Filter = this.filter; + tbFilter.TextChanged += (sender, e) => + { + filter.Text = tbFilter.Text?.ToString() ?? string.Empty; + }; - if (rootObjects.Count > 0) - { - tree.AddObjects(rootObjects); - } - else - { - tree.AddObject("No Objects"); - } - statusBar = new StatusBar(); - string elementDescription = "objects"; + filterErrorLabel = new Label + { + Text = string.Empty, + X = Pos.Right(lblFilter) + 1, + Y = Pos.Top(lblFilter) + 1, + Width = Dim.Fill() - lblFilter.Text!.Length + }; + + if (!applicationData.MinUI) + { + Add(lblFilter); + Add(tbFilter); + Add(filterErrorLabel); + } - var types = rootObjects.Select(o => o.GetType()).Distinct().ToArray(); - if (types.Length == 1) - { - elementDescription = types[0].Name; - } + shortcuts.Add(new Shortcut(Key.Esc, "~ESC~ Close", () => Application.RequestStop())); - 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; + var siCount = new Shortcut(Key.Empty, $"{rootObjects.Count} {elementDescription}", null); + selectedStatusBarItem = new Shortcut(Key.Empty, string.Empty, null); + shortcuts.Add(siCount); + shortcuts.Add(selectedStatusBarItem); - tbFilter.TextChanged += (_) => - { - filter.Text = tbFilter.Text.ToString(); - }; + 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)); + } + statusBar = new StatusBar(shortcuts); + statusBar.Visible = !applicationData.MinUI; + Application.Top!.Add(statusBar); - filterErrorLabel = new Label(string.Empty) - { - X = Pos.Right(lblFilter) + 1, - Y = Pos.Top(lblFilter) + 1, - ColorScheme = Colors.Base, - Width = Dim.Fill() - lblFilter.Text.Length - }; + Add(tree); +} - if (!applicationData.MinUI) - { - Add(lblFilter); - Add(tbFilter); - Add(filterErrorLabel); - } + internal void SetRegexError(string error) + { + if (string.Equals(error, filterErrorLabel.Text?.ToString(), StringComparison.Ordinal)) + { + return; + } + filterErrorLabel.Text = error; + filterErrorLabel.SchemeName = "Error"; + } - int pos = 0; - statusBar.AddItemAt(pos++, new StatusItem(Key.Esc, "~ESC~ Close", () => Application.RequestStop())); + private void SelectionChanged(object? sender, SelectionChangedEventArgs e) + { + var selectedValue = e.NewValue; - 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 (selectedValue is CachedMemberResult cmr) + { + selectedValue = cmr.Value; + } - 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)); - } + if (selectedValue != null && selectedStatusBarItem != null) + { + selectedStatusBarItem.Title = selectedValue.GetType().Name; + } + else if (selectedStatusBarItem != null) + { + selectedStatusBarItem.Title = string.Empty; + } - statusBar.Visible = !applicationData.MinUI; - Application.Top.Add(statusBar); + statusBar?.SetNeedsDraw(); + } - Add(tree); + private string? AspectGetter(object toRender) + { + if (toRender is Process p) + { + return p.ProcessName; } - private void SetRegexError(string error) + if (toRender is null) { - if (string.Equals(error, filterErrorLabel.Text.ToString(), StringComparison.Ordinal)) - { - return; - } - filterErrorLabel.Text = error; - filterErrorLabel.ColorScheme = Colors.Error; - filterErrorLabel.Redraw(filterErrorLabel.Bounds); + return "Null"; + } + if (toRender is FileSystemInfo fsi && !IsRootObject(fsi)) + { + return fsi.Name; } - private void SelectionChanged(object sender, SelectionChangedEventArgs e) + return toRender.ToString(); + } + + private bool IsRootObject(object o) + { + return tree.Objects.Contains(o); + } + + public bool CanExpand(object toExpand) + { + if (toExpand is CachedMemberResult p) { - var selectedValue = e.NewValue; + return IsBasicType(p?.Value); + } - if (selectedValue is CachedMemberResult cmr) - { - selectedValue = cmr.Value; - } + // Any complex object type can be expanded to reveal properties + return IsBasicType(toExpand); + } - if (selectedValue != null && selectedStatusBarItem != null) - { - selectedStatusBarItem.Title = selectedValue.GetType().Name; - } - else - { - selectedStatusBarItem.Title = string.Empty; - } + private static bool IsBasicType(object? value) + { + return value != null && value is not string && !value.GetType().IsValueType; + } - statusBar.SetNeedsDisplay(); + public IEnumerable GetChildren(object forObject) + { + if (forObject == null || !this.CanExpand(forObject)) + { + return Enumerable.Empty(); } - private string AspectGetter(object toRender) + if (forObject is CachedMemberResult p) { - if (toRender is Process p) + if (p.IsCollection) { - return p.ProcessName; + return p.Elements ?? Enumerable.Empty(); } - if (toRender is null) + + 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) { - return "Null"; + children.Add(new CachedMemberResult(forObject, prop)); } - if (toRender is FileSystemInfo fsi && !IsRootObject(fsi)) + if (member is FieldInfo field) { - return fsi.Name; + children.Add(new CachedMemberResult(forObject, field)); } - - return toRender.ToString(); } - private bool IsRootObject(object o) + try + { + children.AddRange(GetExtraChildren(forObject)); + } + catch (Exception) { - return tree.Objects.Contains(o); + // Extra children unavailable, possibly security or IO exceptions enumerating children etc } - public bool CanExpand(object toExpand) + return children; + } + + private static IEnumerable GetExtraChildren(object forObject) + { + if (forObject is DirectoryInfo dir) { - if (toExpand is CachedMemberResult p) + foreach (var c in dir.EnumerateFileSystemInfos()) { - return IsBasicType(p?.Value); + yield return c; } + } + } - // Any complex object type can be expanded to reveal properties - return IsBasicType(toExpand); + internal static void Run(List objects, ApplicationData applicationData) + { + // In Terminal.Gui v2, Application.Init() no longer accepts a driver parameter. + // Instead, use Application.ForceDriver to specify the driver. + if (applicationData.UseNetDriver) + { + Application.ForceDriver = "NetDriver"; } + Application.Init(); + Window? window = null; - private static bool IsBasicType(object value) + try + { + window = new ShowObjectView(objects.Select(p => p.BaseObject).ToList(), applicationData); + Application.Top!.Add(window); + Application.Run(); + } + finally { - return value != null && value is not string && !value.GetType().IsValueType; + Application.Shutdown(); + window?.Dispose(); } + } +} - public IEnumerable GetChildren(object forObject) +sealed class CachedMemberResultElement +{ + public int Index; + public object? Value; + + private readonly string representation; + + public CachedMemberResultElement(object? value, int index) + { + Index = index; + Value = value; + + try { - if (forObject == null || !this.CanExpand(forObject)) - { - return Enumerable.Empty(); - } + representation = Value?.ToString() ?? "Null"; + } + catch (Exception) + { + Value = representation = "Unavailable"; + } + } + public override string ToString() + { + return $"[{Index}]: {representation}]"; + } +} - if (forObject is CachedMemberResult p) - { - if (p.IsCollection) - { - return p.Elements; - } +sealed class CachedMemberResult +{ + public MemberInfo Member; + public object? Value; + public object Parent; + private readonly string representation; + private List? valueAsList; - return GetChildren(p.Value); - } - if (forObject is CachedMemberResultElement e) - { - return GetChildren(e.Value); - } + public bool IsCollection => valueAsList != null; + public IReadOnlyCollection? Elements => valueAsList?.AsReadOnly(); - List children = new List(); + public CachedMemberResult(object parent, MemberInfo mem) + { + Parent = parent; + Member = mem; - foreach (var member in forObject.GetType().GetMembers(BindingFlags.Instance | BindingFlags.Public).OrderBy(m => m.Name)) + try + { + if (mem is PropertyInfo p) { - if (member is PropertyInfo prop) - { - children.Add(new CachedMemberResult(forObject, prop)); - } - if (member is FieldInfo field) - { - children.Add(new CachedMemberResult(forObject, field)); - } + Value = p.GetValue(parent); } - - try + else if (mem is FieldInfo f) { - children.AddRange(GetExtraChildren(forObject)); + Value = f.GetValue(parent); } - catch (Exception) + else { - // Extra children unavailable, possibly security or IO exceptions enumerating children etc + throw new NotSupportedException($"Unknown {nameof(MemberInfo)} Type"); } - return children; - } + representation = ValueToString(); - private static IEnumerable GetExtraChildren(object forObject) + } + catch (Exception) { - if (forObject is DirectoryInfo dir) - { - foreach (var c in dir.EnumerateFileSystemInfos()) - { - yield return c; - } - } + Value = representation = "Unavailable"; } + } - internal static void Run(List objects, ApplicationData applicationData) + private string? ValueToString() + { + if (Value == null) { - // 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 + return "Null"; + } + try + { + if (IsCollectionOfKnownTypeAndSize(out Type? elementType, out int size)) { - Application.Shutdown(); - window?.Dispose(); + return $"{elementType!.Name}[{size}]"; } } - - sealed class CachedMemberResultElement + catch (Exception) { - public int Index; - public object Value; + return Value?.ToString(); + } - 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}]"; - } - } + return Value?.ToString(); + } - sealed class CachedMemberResult - { - public MemberInfo Member; - public object Value; - public object Parent; - private string representation; - private List valueAsList; + private bool IsCollectionOfKnownTypeAndSize(out Type? elementType, out int size) + { + elementType = null; + size = 0; + if (Value == null || Value is string) + { - public bool IsCollection => valueAsList != null; - public IReadOnlyCollection Elements => valueAsList?.AsReadOnly(); + return false; + } - 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"; - } - } + if (Value is IEnumerable ienumerable) + { + var list = ienumerable.Cast().ToList(); - 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(); - } + var types = list.Where(v => v != null).Select(v => v!.GetType()).Distinct().ToArray(); - private bool IsCollectionOfKnownTypeAndSize(out Type elementType, out int size) + if (types.Length == 1) { - elementType = null; - size = 0; + elementType = types[0]; + size = list.Count; - if (Value == null || Value is string) - { - - return false; - } + valueAsList = list.Select((e, i) => new CachedMemberResultElement(e, i)).ToList(); + return true; + } + } - if (Value is IEnumerable ienumerable) - { - var list = ienumerable.Cast().ToList(); + return false; + } - var types = list.Where(v => v != null).Select(v => v.GetType()).Distinct().ToArray(); + public override string ToString() + { + return Member.Name + ": " + representation; + } +} - if (types.Length == 1) - { - elementType = types[0]; - size = list.Count; +sealed class RegexTreeViewTextFilter : ITreeViewFilter +{ + private readonly ShowObjectView parent; + readonly TreeView _forTree; - valueAsList = list.Select((e, i) => new CachedMemberResultElement(e, i)).ToList(); - return true; - } - } + public RegexTreeViewTextFilter(ShowObjectView parent, TreeView forTree) + { + this.parent = parent; + _forTree = forTree ?? throw new ArgumentNullException(nameof(forTree)); + } - return false; - } + private string text = string.Empty; - public override string ToString() - { - return Member.Name + ": " + representation; - } - } - private sealed class RegexTreeViewTextFilter : ITreeViewFilter + public string Text + { + get { return text; } + set { - private readonly ShowObjectView parent; - readonly TreeView _forTree; - - public RegexTreeViewTextFilter(ShowObjectView parent, TreeView forTree) - { - this.parent = parent; - _forTree = forTree ?? throw new ArgumentNullException(nameof(forTree)); - } + text = value; + RefreshTreeView(); + } + } - private string text; + private void RefreshTreeView() + { + _forTree.InvalidateLineMap(); + _forTree.SetNeedsDraw(); + } - public string Text - { - get { return text; } - set - { - text = value; - RefreshTreeView(); - } - } + public bool IsMatch(object model) + { + if (string.IsNullOrWhiteSpace(Text)) + { + return true; + } - private void RefreshTreeView() - { - _forTree.InvalidateLineMap(); - _forTree.SetNeedsDisplay(); - } + parent.SetRegexError(string.Empty); - 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; - } - } + var modelText = _forTree.AspectGetter(model); + try + { + return Regex.IsMatch(modelText ?? string.Empty, text, RegexOptions.IgnoreCase); + } + catch (RegexParseException e) + { + parent.SetRegexError(e.Message); + return true; } } } 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..949d2b5 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/Microsoft.PowerShell.OutGridView.Models.csproj +++ b/src/Microsoft.PowerShell.OutGridView.Models/Microsoft.PowerShell.OutGridView.Models.csproj @@ -2,6 +2,8 @@ net8.0 + latest + enable From ab76e4af203e69d1c07479b365d9d10e6077d96a Mon Sep 17 00:00:00 2001 From: Tig Date: Thu, 13 Nov 2025 12:37:49 -0700 Subject: [PATCH 02/19] WIP: Got it basically working. Refactored `ConsoleGui.cs` to improve modularity, readability, and maintainability. Added dynamic layout handling, improved filtering logic, and enhanced status bar functionality. Simplified data loading and display string updates. Updated `GridViewHelpers.cs` to handle nullable column widths and improve robustness. Replaced `Terminal.Gui` package reference with a local project reference in `Microsoft.PowerShell.ConsoleGuiTools.csproj` to facilitate debugging. Added `` for better build handling. Added new launch profiles in `launchSettings.json` for streamlined debugging with preloaded `Out-ConsoleGridView` module. Upgraded Visual Studio version in `GraphicalTools.sln` to 18.3.11206.111. --- GraphicalTools.sln | 4 +- .../ConsoleGui.cs | 585 +++++++++--------- .../GridViewHelpers.cs | 7 +- ...icrosoft.PowerShell.ConsoleGuiTools.csproj | 7 +- .../Properties/launchSettings.json | 15 + 5 files changed, 327 insertions(+), 291 deletions(-) create mode 100644 src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json diff --git a/GraphicalTools.sln b/GraphicalTools.sln index 62b7314..0789aa8 100644 --- a/GraphicalTools.sln +++ b/GraphicalTools.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.2.0 +# 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 diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs index 3977743..6a2351f 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs @@ -1,15 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using OutGridView.Models; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Reflection; using System.Text; - -using OutGridView.Models; - using Terminal.Gui.App; using Terminal.Gui.Configuration; using Terminal.Gui.Drawing; @@ -29,6 +27,7 @@ internal sealed class ConsoleGui : IDisposable private bool _cancelled; private Label? _filterLabel; private TextField? _filterField; + private Label? _header; 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 @@ -56,122 +55,154 @@ public HashSet Start(ApplicationData applicationData) { // 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 - }; + ListViewOffset = _applicationData.OutputMode != OutputModeOption.None ? MARGIN_LEFT + CHECK_WIDTH : MARGIN_LEFT + }; - Window win = CreateTopLevelWindow(); + 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); + // Create the headers and calculate column widths based on the DataTable + List gridHeaders = _applicationData.DataTable.DataColumns.Select((c) => c.Label).ToList(); - // 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); - } + // 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(); - // Add ListView - AddListView(win); + if (!_applicationData.MinUI) + { + // Add Filter UI + AddFilter(win); + // Add Header UI + AddHeaders(win, gridHeaders); + } - // Status bar is where our key-bindings are handled - AddStatusBar(!_applicationData.MinUI); + // Add ListView + AddListView(win); - // 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(); + // Status bar is where our key-bindings are handled + AddStatusBar(win, !_applicationData.MinUI); - _listView.SetFocus(); + // 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(); - // Run the GUI. - Application.Run(); - Application.Shutdown(); + _listView?.SetFocus(); - // Return results of selection if required. - HashSet selectedIndexes = new HashSet(); - if (_cancelled) - { - return selectedIndexes; - } + win.SubViewLayout += OnWinSubViewLayout; - // Return any items that were selected. - foreach (GridViewRow gvr in _inputSource.GridViewRowList) - { - if (gvr.IsMarked) - { - selectedIndexes.Add(gvr.OriginalIndex); - } - } + // Run the GUI. + Application.Run(win); + win.Dispose(); + Application.Shutdown(); + // Return results of selection if required. + HashSet selectedIndexes = new HashSet(); + if (_cancelled) + { return selectedIndexes; } - private GridViewDataSource LoadData() + // Return any items that were selected. + foreach (GridViewRow gvr in _inputSource.GridViewRowList) { - var items = new List(); - int newIndex = 0; - for (int i = 0; i < _applicationData.DataTable.Data.Count; i++) + if (gvr.IsMarked) { - 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); - } + selectedIndexes.Add(gvr.OriginalIndex); + } + } - string displayString = GridViewHelpers.GetPaddedString(valueList, 0, _gridViewDetails.ListViewColumnWidths); + return selectedIndexes; - items.Add(new GridViewRow - { - DisplayString = displayString, - // We use this to keep _inputSource up to date when a filter is applied - OriginalIndex = i - }); + void OnWinSubViewLayout(object? sender, EventArgs e) + { + CalculateColumnWidths(gridHeaders); - newIndex++; - } + _header!.Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails!.ListViewOffset, + _gridViewDetails.ListViewColumnWidths); + ApplyFilter(); + UpdateDisplayStrings(_listViewSource); + } + } + private GridViewDataSource LoadData() + { + var items = new List(); + if (_applicationData == null) return new GridViewDataSource(items); - } - private void ApplyFilter() + for (int i = 0; i < _applicationData.DataTable.Data.Count; i++) { - // 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) + var dataTableRow = _applicationData.DataTable.Data[i]; + var valueList = new List(); + foreach (var dataTableColumn in _applicationData.DataTable.DataColumns) { - _listViewSource.MarkChanged -= ListViewSource_MarkChanged; - _listViewSource = null; + string dataValue = dataTableRow.Values[dataTableColumn.ToString()].DisplayValue; + valueList.Add(dataValue); } - _listViewSource = new GridViewDataSource(GridViewHelpers.FilterData(_inputSource.GridViewRowList, _applicationData.Filter ?? string.Empty)); - _listViewSource.MarkChanged += ListViewSource_MarkChanged; - _listView.Source = _listViewSource; + 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 + }); } - private void ListViewSource_MarkChanged(object s, GridViewDataSource.RowMarkedEventArgs a) + return new GridViewDataSource(items); + } + + private void UpdateDisplayStrings(GridViewDataSource? source) + { + if (source == null) return; + foreach (var gvr in source.GridViewRowList) { - _inputSource.GridViewRowList[a.Row.OriginalIndex].IsMarked = a.Row.IsMarked; + var valueList = new List(); + var dataTableRow = _applicationData!.DataTable.Data[gvr.OriginalIndex]; + foreach (var dataTableColumn in _applicationData.DataTable.DataColumns) + { + string dataValue = dataTableRow.Values[dataTableColumn.ToString()].DisplayValue; + valueList.Add(dataValue); + } + gvr.DisplayString = GridViewHelpers.GetPaddedString(valueList, 0, _gridViewDetails?.ListViewColumnWidths); } + } + 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. - private static void Accept() + if (_listViewSource != null) { - Application.RequestStop(); + _listViewSource.MarkChanged -= ListViewSource_MarkChanged; + _listViewSource = null; } - private void Close() + if (_inputSource is null) { - _cancelled = true; - Application.RequestStop(); + _inputSource = LoadData(); + } + if (_applicationData != 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() @@ -193,258 +224,238 @@ private Window CreateTopLevelWindow() win.BorderStyle = LineStyle.None; } - Application.Top!.Add(win); return win; } - private void AddStatusBar(bool visible) + private void AddStatusBar(Window win, bool visible) + { + var shortcuts = new List(); + if (_applicationData!.OutputMode != OutputModeOption.None) { - var shortcuts = new List(); - if (_applicationData!.OutputMode != OutputModeOption.None) - { - // Use Key.Empty for SPACE with no delegate because ListView already - // handles SPACE - shortcuts.Add(new Shortcut(Key.Empty, "~SPACE~ Select Item", null)); - } + // Use Key.Empty for SPACE with no delegate because ListView already + // handles SPACE + shortcuts.Add(new Shortcut(Key.Space, "Select Item", null)); + } - if (_applicationData.OutputMode == OutputModeOption.Multiple) + if (_applicationData.OutputMode == OutputModeOption.Multiple) + { + shortcuts.Add(new Shortcut(Key.A.WithCtrl, "Select All", () => { - shortcuts.Add(new Shortcut(Key.A.WithCtrl, "~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.SetNeedsDraw(); - })); - - // Ctrl-D is commonly used in GUIs for select-none - shortcuts.Add(new Shortcut(Key.D.WithCtrl, "~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.SetNeedsDraw(); - })); - } + // This selects only the items that match the Filter + var gvds = _listView!.Source as GridViewDataSource; + gvds!.GridViewRowList.ForEach(i => i.IsMarked = true); + _listView.SetNeedsDraw(); + })); + + // Ctrl-D is commonly used in GUIs for select-none + shortcuts.Add(new Shortcut(Key.D.WithCtrl, "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.SetNeedsDraw(); + })); + } - if (_applicationData.OutputMode != OutputModeOption.None) + if (_applicationData.OutputMode != OutputModeOption.None) + { + shortcuts.Add(new Shortcut(Key.Enter, "Accept", () => { - shortcuts.Add(new Shortcut(Key.Enter, "~ENTER~ Accept", () => + if (Application.Top?.MostFocused == _listView) { - 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) { - // 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) + // Toggle the mark on the currently selected item + if (_listView!.SelectedItem >= 0 && _listView.SelectedItem < _listViewSource!.Count) { - // Toggle the mark on the currently selected item - if (_listView!.SelectedItem >= 0 && _listView.SelectedItem < _listViewSource!.Count) - { - var item = _listViewSource.GridViewRowList[_listView.SelectedItem]; - item.IsMarked = !item.IsMarked; - } + 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, "~ESC~ Close", () => Close())); - 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)); - } + Accept(); + } + else if (Application.Top?.MostFocused == _filterField) + { + _listView!.SetFocus(); + } + })); + } - var statusBar = new StatusBar(shortcuts); - statusBar.Visible = visible; - Application.Top!.Add(statusBar); + shortcuts.Add(new Shortcut(Key.Esc, "Close", Close)); + 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)); } - private void CalculateColumnWidths(List gridHeaders) + var statusBar = new StatusBar(shortcuts); + statusBar.Visible = visible; + win.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++) { - _gridViewDetails.ListViewColumnWidths = new int[gridHeaders.Count]; - var listViewColumnWidths = _gridViewDetails.ListViewColumnWidths; + listViewColumnWidths[i] = gridHeaders[i].Length; + } - 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; - // calculate the width of each column based on longest string in each column for each row - foreach (var row in _applicationData.DataTable.Data) + // 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)) { - 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]) { - var len = col.Value.DisplayValue.Length; - if (len > listViewColumnWidths[index]) - { - listViewColumnWidths[index] = len; - } - 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) + // 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++) { - int maxWidth = 0; - int maxIndex = 0; - for (int i = 0; i < listViewColumnWidths.Length; i++) + if (listViewColumnWidths[i] > maxWidth) { - if (listViewColumnWidths[i] > maxWidth) - { - maxWidth = listViewColumnWidths[i]; - maxIndex = i; - } + maxWidth = listViewColumnWidths[i]; + maxIndex = i; } - - listViewColumnWidths[maxIndex]--; - columnWidthsSum--; } + + listViewColumnWidths[maxIndex]--; + columnWidthsSum--; } + } - private void AddFilter(Window win) + private void AddFilter(Window win) + { + _filterLabel = new Label { - _filterLabel = new Label - { - Text = FILTER_LABEL, - X = MARGIN_LEFT, - Y = 0 - }; + 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 = new TextField + { + Text = _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.KeyBindings.Remove(Key.A.WithCtrl); + _filterField.KeyBindings.Remove(Key.D.WithCtrl); - // 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.KeyBindings.Remove(Key.A.WithCtrl); - _filterField.KeyBindings.Remove(Key.D.WithCtrl); + var filterErrorLabel = new Label + { + Text = string.Empty, + X = Pos.Right(_filterLabel) + 1, + Y = Pos.Top(_filterLabel) + 1, + Width = Dim.Fill() - _filterLabel.Text!.Length + }; - var filterErrorLabel = new Label + _filterField.TextChanged += (sender, e) => + { + string? filterText = _filterField.Text?.ToString(); + try { - Text = string.Empty, - X = Pos.Right(_filterLabel) + 1, - Y = Pos.Top(_filterLabel) + 1, - Width = Dim.Fill() - _filterLabel.Text!.Length - }; + filterErrorLabel.Text = " "; + filterErrorLabel.SetNeedsDraw(); + _applicationData!.Filter = filterText; + ApplyFilter(); - _filterField.TextChanged += (sender, e) => + } + catch (Exception ex) { - string? filterText = _filterField.Text?.ToString(); - try - { - filterErrorLabel.Text = " "; - filterErrorLabel.SetNeedsDraw(); - _applicationData!.Filter = filterText; - ApplyFilter(); + filterErrorLabel.Text = ex.Message; + filterErrorLabel.SchemeName = "Error"; + } + }; - } - catch (Exception ex) - { - filterErrorLabel.Text = ex.Message; - filterErrorLabel.SchemeName = "Error"; - } - }; + win.Add(_filterLabel, _filterField, filterErrorLabel); - win.Add(_filterLabel, _filterField, filterErrorLabel); + _filterField.Text = _applicationData.Filter ?? string.Empty; + _filterField.CursorPosition = _filterField.Text.Length; + } - _filterField.Text = _applicationData.Filter ?? string.Empty; - _filterField.CursorPosition = _filterField.Text.Length; + private void AddHeaders(Window win, List gridHeaders) + { + _header = new Label + { + //Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails!.ListViewOffset, _gridViewDetails.ListViewColumnWidths), + }; + if (_applicationData!.MinUI) + { + _header.Y = 0; + } + else + { + _header.Y = 2; } + win.Add(_header); - private void AddHeaders(Window win, List gridHeaders) + if (!_applicationData.MinUI) { - var header = new Label + var headerLine = new Line() { - Text = GridViewHelpers.GetPaddedString( - gridHeaders, - _gridViewDetails!.ListViewOffset, - _gridViewDetails.ListViewColumnWidths), - X = 0 + X = MARGIN_LEFT, + Y = Pos.Bottom(_header), + Width = Dim.Fill(MARGIN_LEFT), }; - 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 - { - Text = headerLineText.ToString(), - X = 0, - Y = Pos.Bottom(header) - }; - win.Add(headerLine); - } + win.Add(headerLine); } + } - private void AddListView(Window win) + private void AddListView(Window win) + { + _listView = new ListView { - _listView = new ListView - { - Source = _inputSource, - 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; - - // In Terminal.Gui v2, key bindings work differently - // The ListView already handles Space for toggling marks by default - - win.Add(_listView); + Source = _inputSource, + X = MARGIN_LEFT + }; + if (!_applicationData!.MinUI) + { + _listView.Y = Pos.Bottom(_filterLabel!) + 2; // 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; + + // In Terminal.Gui v2, key bindings work differently + // The ListView already handles Space for toggling marks by default + + win.Add(_listView); + } public void Dispose() { diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs index 482fe1d..d566b59 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs @@ -26,8 +26,13 @@ public static List FilterData(List listToFilter, strin 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) { diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj b/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj index ec7ba70..278c473 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj @@ -17,7 +17,8 @@ - + + @@ -34,4 +35,8 @@ true Recommended + + + true + diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json new file mode 100644 index 0000000..de53ceb --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "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)" + }, + "Debug OCGV": { + "commandName": "Executable", + "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView }\"", + "workingDirectory": "$(TargetDir)" + } + } +} \ No newline at end of file From 8eccca85f15430e90c4be3b9d2d3d2f33c536e0d Mon Sep 17 00:00:00 2001 From: Tig Date: Thu, 13 Nov 2025 17:38:28 -0700 Subject: [PATCH 03/19] WIP: Fixing bugs - -Filter is not working right Enhanced the filtering functionality by introducing `_filterErrorView` to display filter-related errors and updated the `ApplyFilter` method to handle `RegexParseException` gracefully. Adjusted `_header` positioning dynamically based on `_filterErrorView` and ensured `ListView` selection defaults to the first item if none is selected. Refactored `ListViewSource_MarkChanged` to handle null `_inputSource` safely. Simplified `StatusBar` creation and improved `filterErrorView` behavior with `Dim.Auto` for dynamic height and error-specific styling. Updated `launchSettings.json` with new configurations (`OCGV -Filter`, `OCGV`, `OCGV -MinUi`) for better debugging flexibility. Cleaned up redundant code, improved null safety, and adjusted `Window` dimensions for `MinUI` mode. --- .../ConsoleGui.cs | 79 +++++++++++++------ .../Properties/launchSettings.json | 14 +++- 2 files changed, 66 insertions(+), 27 deletions(-) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs index 6a2351f..3383e98 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Reflection; using System.Text; +using System.Text.RegularExpressions; using Terminal.Gui.App; using Terminal.Gui.Configuration; using Terminal.Gui.Drawing; @@ -27,6 +28,7 @@ internal sealed class ConsoleGui : IDisposable private bool _cancelled; private Label? _filterLabel; private TextField? _filterField; + private View? _filterErrorView; private Label? _header; private ListView? _listView; // _inputSource contains the full set of Input data and tracks any items the user @@ -119,15 +121,15 @@ void OnWinSubViewLayout(object? sender, EventArgs e) _header!.Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails!.ListViewOffset, _gridViewDetails.ListViewColumnWidths); - ApplyFilter(); UpdateDisplayStrings(_listViewSource); + ApplyFilter(); } } private GridViewDataSource LoadData() { var items = new List(); - if (_applicationData == null) + if (_applicationData == null) return new GridViewDataSource(items); for (int i = 0; i < _applicationData.DataTable.Data.Count; i++) @@ -173,9 +175,13 @@ 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. + GridViewRow? selectedItem = null; + if (_listViewSource != null) { - _listViewSource.MarkChanged -= ListViewSource_MarkChanged; + // Get the item that is currently selected so we can restore selection after re-applying filter + selectedItem = _listViewSource?.GridViewRowList.ElementAtOrDefault(_listView?.SelectedItem ?? 0); + _listViewSource!.MarkChanged -= ListViewSource_MarkChanged; _listViewSource = null; } @@ -183,15 +189,43 @@ private void ApplyFilter() { _inputSource = LoadData(); } + + if (_applicationData != null) - _listViewSource = new GridViewDataSource(GridViewHelpers.FilterData(_inputSource.GridViewRowList, _applicationData.Filter ?? string.Empty)); + { + try + { + _listViewSource = new GridViewDataSource(GridViewHelpers.FilterData(_inputSource.GridViewRowList, + _applicationData.Filter ?? string.Empty)); + } + catch (RegexParseException ex) + { + _filterErrorView!.Text = ex.Message; + } + } + _listViewSource?.MarkChanged += ListViewSource_MarkChanged; _listView?.Source = _listViewSource; + + // Restore selection - find the previously selected item in the new filtered list + if (selectedItem is not null && _listViewSource != null) + { + int newIndex = + _listViewSource.GridViewRowList.FindIndex(i => i.OriginalIndex == selectedItem.OriginalIndex); + if (newIndex >= 0) + { + _listView!.SelectedItem = newIndex; + } + } + if (_listView?.SelectedItem == -1) + { + _listView!.SelectedItem = 0; + } } private void ListViewSource_MarkChanged(object? s, GridViewDataSource.RowMarkedEventArgs a) { - _inputSource.GridViewRowList[a.Row.OriginalIndex].IsMarked = a.Row.IsMarked; + _inputSource?.GridViewRowList[a.Row.OriginalIndex].IsMarked = a.Row.IsMarked; } private static void Accept() @@ -211,12 +245,6 @@ private Window CreateTopLevelWindow() var win = new Window { Title = _applicationData!.Title ?? "Out-ConsoleGridView", - 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) @@ -293,14 +321,12 @@ private void AddStatusBar(Window win, bool visible) $"{Application.Driver} v{FileVersionInfo.GetVersionInfo(Assembly.GetAssembly(typeof(Application))!.Location).ProductVersion}", null)); } - var statusBar = new StatusBar(shortcuts); - statusBar.Visible = visible; - win.Add(statusBar); + win.Add(new StatusBar(shortcuts)); } private void CalculateColumnWidths(List gridHeaders) { - _gridViewDetails.ListViewColumnWidths = new int[gridHeaders.Count]; + _gridViewDetails!.ListViewColumnWidths = new int[gridHeaders.Count]; var listViewColumnWidths = _gridViewDetails.ListViewColumnWidths; for (int i = 0; i < gridHeaders.Count; i++) @@ -371,12 +397,15 @@ private void AddFilter(Window win) _filterField.KeyBindings.Remove(Key.A.WithCtrl); _filterField.KeyBindings.Remove(Key.D.WithCtrl); - var filterErrorLabel = new Label + _filterErrorView = new View { Text = string.Empty, X = Pos.Right(_filterLabel) + 1, Y = Pos.Top(_filterLabel) + 1, - Width = Dim.Fill() - _filterLabel.Text!.Length + Width = Dim.Fill() - _filterLabel.Text!.Length, + // This enables the height to go 0, and the view to disappear when there is no error + Height = Dim.Auto(DimAutoStyle.Text), + SchemeName = "Error" }; _filterField.TextChanged += (sender, e) => @@ -384,20 +413,19 @@ private void AddFilter(Window win) string? filterText = _filterField.Text?.ToString(); try { - filterErrorLabel.Text = " "; - filterErrorLabel.SetNeedsDraw(); - _applicationData!.Filter = filterText; + _filterErrorView.Text = string.Empty; + _filterErrorView.SetNeedsDraw(); + _applicationData!.Filter = filterText!; ApplyFilter(); } catch (Exception ex) { - filterErrorLabel.Text = ex.Message; - filterErrorLabel.SchemeName = "Error"; + _filterErrorView.Text = ex.Message; } }; - win.Add(_filterLabel, _filterField, filterErrorLabel); + win.Add(_filterLabel, _filterField, _filterErrorView); _filterField.Text = _applicationData.Filter ?? string.Empty; _filterField.CursorPosition = _filterField.Text.Length; @@ -415,7 +443,7 @@ private void AddHeaders(Window win, List gridHeaders) } else { - _header.Y = 2; + _header.Y = Pos.Bottom(_filterErrorView!); } win.Add(_header); @@ -451,8 +479,7 @@ private void AddListView(Window win) _listView.AllowsMarking = _applicationData.OutputMode != OutputModeOption.None; _listView.AllowsMultipleSelection = _applicationData.OutputMode == OutputModeOption.Multiple; - // In Terminal.Gui v2, key bindings work differently - // The ListView already handles Space for toggling marks by default + _listView.SelectedItem = 0; win.Add(_listView); } diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json index de53ceb..74a3e81 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json @@ -5,11 +5,23 @@ "commandLineArgs": "C:\\Program Files\\PowerShell\\7\\pwsh.exe -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView }\"", "workingDirectory": "$(TargetDir)" }, - "Debug OCGV": { + "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-Process | 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)" } } } \ No newline at end of file From 5795efad388c53c6354d7cae0995b40b34d27b48 Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 14 Nov 2025 08:42:47 -0700 Subject: [PATCH 04/19] Fixed Select All bug. Refactored `OnWinSubViewLayout` to add null checks for `_header` to ensure safety and prevent runtime exceptions. Simplified "Select All" and "Select None" logic by replacing manual iteration with `_listView?.MarkAll` calls and added comments to address a `Terminal.Gui` bug requiring explicit redraws. Removed default `Ctrl-A` keybinding from `_listView` to allow custom handling in the status bar. Updated `LangVersion` in `Microsoft.PowerShell.ConsoleGuiTools.csproj` to `preview` to enable preview language features. Fixed formatting in the `ProjectReference` element for consistency. --- .../ConsoleGui.cs | 18 +++++++++--------- ...Microsoft.PowerShell.ConsoleGuiTools.csproj | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs index 3383e98..462887d 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs @@ -119,7 +119,8 @@ void OnWinSubViewLayout(object? sender, EventArgs e) { CalculateColumnWidths(gridHeaders); - _header!.Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails!.ListViewOffset, + if (_header is not null) + _header.Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails!.ListViewOffset, _gridViewDetails.ListViewColumnWidths); UpdateDisplayStrings(_listViewSource); ApplyFilter(); @@ -269,19 +270,15 @@ private void AddStatusBar(Window win, bool visible) { shortcuts.Add(new Shortcut(Key.A.WithCtrl, "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.SetNeedsDraw(); + _listView?.MarkAll(true); + _listView?.SetNeedsDraw(); // Bug in Terminal.Gui where MarkAll doesn't refresh display })); // Ctrl-D is commonly used in GUIs for select-none shortcuts.Add(new Shortcut(Key.D.WithCtrl, "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.SetNeedsDraw(); + _listView?.MarkAll(false); + _listView?.SetNeedsDraw(); // Bug in Terminal.Gui where MarkAll doesn't refresh display })); } @@ -481,6 +478,9 @@ private void AddListView(Window win) _listView.SelectedItem = 0; + // ListView captures Ctrl-A (select all) - we handle this in the status bar + _listView.KeyBindings.Remove(Key.A.WithCtrl); + win.Add(_listView); } diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj b/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj index 278c473..9f47c79 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj @@ -2,7 +2,7 @@ net8.0 - latest + preview enable @@ -23,7 +23,7 @@ - + From 9af07aa3f60521d059bf9ac0c7131f179a6fa9db Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 14 Nov 2025 08:45:06 -0700 Subject: [PATCH 05/19] Code cleanup --- GraphicalTools.sln.DotSettings | 2 + .../ConsoleGui.cs | 135 +++++++----------- 2 files changed, 50 insertions(+), 87 deletions(-) create mode 100644 GraphicalTools.sln.DotSettings diff --git a/GraphicalTools.sln.DotSettings b/GraphicalTools.sln.DotSettings new file mode 100644 index 0000000..c8249bf --- /dev/null +++ b/GraphicalTools.sln.DotSettings @@ -0,0 +1,2 @@ + + <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> \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs index 462887d..941c2be 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs @@ -1,16 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using OutGridView.Models; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Reflection; -using System.Text; using System.Text.RegularExpressions; +using OutGridView.Models; using Terminal.Gui.App; -using Terminal.Gui.Configuration; using Terminal.Gui.Drawing; using Terminal.Gui.Input; using Terminal.Gui.ViewBase; @@ -21,8 +19,10 @@ 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; @@ -30,7 +30,9 @@ internal sealed class ConsoleGui : IDisposable private TextField? _filterField; private View? _filterErrorView; private Label? _header; + 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 @@ -48,22 +50,21 @@ public HashSet Start(ApplicationData applicationData) _applicationData = applicationData; // In Terminal.Gui v2, Application.Init() no longer accepts a driver parameter. // Instead, use Application.ForceDriver to specify the driver. - if (_applicationData.UseNetDriver) - { - Application.ForceDriver = "NetDriver"; - } + if (_applicationData.UseNetDriver) Application.ForceDriver = "NetDriver"; 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 + ListViewOffset = _applicationData.OutputMode != OutputModeOption.None + ? MARGIN_LEFT + CHECK_WIDTH + : MARGIN_LEFT }; - Window win = CreateTopLevelWindow(); + var win = CreateTopLevelWindow(); // Create the headers and calculate column widths based on the DataTable - List gridHeaders = _applicationData.DataTable.DataColumns.Select((c) => c.Label).ToList(); + var gridHeaders = _applicationData.DataTable.DataColumns.Select(c => c.Label).ToList(); // Copy the input DataTable into our master ListView source list; upon exit any items // that are IsMarked are returned (if Outputmode is set) @@ -98,20 +99,13 @@ public HashSet Start(ApplicationData applicationData) Application.Shutdown(); // Return results of selection if required. - HashSet selectedIndexes = new HashSet(); - if (_cancelled) - { - return selectedIndexes; - } + var selectedIndexes = new HashSet(); + if (_cancelled) return selectedIndexes; // Return any items that were selected. - foreach (GridViewRow gvr in _inputSource.GridViewRowList) - { + foreach (var gvr in _inputSource.GridViewRowList) if (gvr.IsMarked) - { selectedIndexes.Add(gvr.OriginalIndex); - } - } return selectedIndexes; @@ -119,9 +113,9 @@ void OnWinSubViewLayout(object? sender, EventArgs e) { CalculateColumnWidths(gridHeaders); - if (_header is not null) + if (_header is { }) _header.Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails!.ListViewOffset, - _gridViewDetails.ListViewColumnWidths); + _gridViewDetails.ListViewColumnWidths); UpdateDisplayStrings(_listViewSource); ApplyFilter(); } @@ -133,17 +127,17 @@ private GridViewDataSource LoadData() if (_applicationData == null) return new GridViewDataSource(items); - for (int i = 0; i < _applicationData.DataTable.Data.Count; i++) + for (var 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; + var dataValue = dataTableRow.Values[dataTableColumn.ToString()].DisplayValue; valueList.Add(dataValue); } - string displayString = GridViewHelpers.GetPaddedString(valueList, 0, _gridViewDetails?.ListViewColumnWidths); + var displayString = GridViewHelpers.GetPaddedString(valueList, 0, _gridViewDetails?.ListViewColumnWidths); items.Add(new GridViewRow { @@ -165,12 +159,14 @@ private void UpdateDisplayStrings(GridViewDataSource? source) var dataTableRow = _applicationData!.DataTable.Data[gvr.OriginalIndex]; foreach (var dataTableColumn in _applicationData.DataTable.DataColumns) { - string dataValue = dataTableRow.Values[dataTableColumn.ToString()].DisplayValue; + var dataValue = dataTableRow.Values[dataTableColumn.ToString()].DisplayValue; valueList.Add(dataValue); } + gvr.DisplayString = GridViewHelpers.GetPaddedString(valueList, 0, _gridViewDetails?.ListViewColumnWidths); } } + private void ApplyFilter() { // The ListView is always filled with a (filtered) copy of _inputSource. @@ -186,14 +182,10 @@ private void ApplyFilter() _listViewSource = null; } - if (_inputSource is null) - { - _inputSource = LoadData(); - } + _inputSource ??= LoadData(); if (_applicationData != null) - { try { _listViewSource = new GridViewDataSource(GridViewHelpers.FilterData(_inputSource.GridViewRowList, @@ -203,25 +195,19 @@ private void ApplyFilter() { _filterErrorView!.Text = ex.Message; } - } _listViewSource?.MarkChanged += ListViewSource_MarkChanged; _listView?.Source = _listViewSource; // Restore selection - find the previously selected item in the new filtered list - if (selectedItem is not null && _listViewSource != null) + if (selectedItem is { } && _listViewSource != null) { - int newIndex = + var newIndex = _listViewSource.GridViewRowList.FindIndex(i => i.OriginalIndex == selectedItem.OriginalIndex); - if (newIndex >= 0) - { - _listView!.SelectedItem = newIndex; - } - } - if (_listView?.SelectedItem == -1) - { - _listView!.SelectedItem = 0; + if (newIndex >= 0) _listView!.SelectedItem = newIndex; } + + if (_listView?.SelectedItem == -1) _listView!.SelectedItem = 0; } private void ListViewSource_MarkChanged(object? s, GridViewDataSource.RowMarkedEventArgs a) @@ -245,13 +231,10 @@ private Window CreateTopLevelWindow() // Creates the top-level window to show var win = new Window { - Title = _applicationData!.Title ?? "Out-ConsoleGridView", + Title = _applicationData!.Title ?? "Out-ConsoleGridView" }; - if (_applicationData.MinUI) - { - win.BorderStyle = LineStyle.None; - } + if (_applicationData.MinUI) win.BorderStyle = LineStyle.None; return win; } @@ -260,11 +243,9 @@ private void AddStatusBar(Window win, bool visible) { var shortcuts = new List(); if (_applicationData!.OutputMode != OutputModeOption.None) - { // Use Key.Empty for SPACE with no delegate because ListView already // handles SPACE shortcuts.Add(new Shortcut(Key.Space, "Select Item", null)); - } if (_applicationData.OutputMode == OutputModeOption.Multiple) { @@ -283,7 +264,6 @@ private void AddStatusBar(Window win, bool visible) } if (_applicationData.OutputMode != OutputModeOption.None) - { shortcuts.Add(new Shortcut(Key.Enter, "Accept", () => { if (Application.Top?.MostFocused == _listView) @@ -293,14 +273,13 @@ private void AddStatusBar(Window win, bool visible) // (using SPACE) then honor that as the single item to return if (_applicationData.OutputMode == OutputModeOption.Single && _inputSource!.GridViewRowList.Find(i => i.IsMarked) == null) - { // Toggle the mark on the currently selected item 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) @@ -308,14 +287,14 @@ private void AddStatusBar(Window win, bool visible) _listView!.SetFocus(); } })); - } shortcuts.Add(new Shortcut(Key.Esc, "Close", Close)); 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)); + $"{Application.Driver} v{FileVersionInfo.GetVersionInfo(Assembly.GetAssembly(typeof(Application))!.Location).ProductVersion}", + null)); } win.Add(new StatusBar(shortcuts)); @@ -326,43 +305,36 @@ 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; - } + for (var 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) + foreach (var row in _applicationData!.DataTable.Data) { - int index = 0; + var 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)) + 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; - } + 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(); + _gridViewDetails.UsableWidth = Application.Top!.Frame.Width - MARGIN_LEFT - listViewColumnWidths.Length - + _gridViewDetails.ListViewOffset; + var columnWidthsSum = listViewColumnWidths.Sum(); while (columnWidthsSum >= _gridViewDetails.UsableWidth) { - int maxWidth = 0; - int maxIndex = 0; - for (int i = 0; i < listViewColumnWidths.Length; i++) - { + var maxWidth = 0; + var maxIndex = 0; + for (var i = 0; i < listViewColumnWidths.Length; i++) if (listViewColumnWidths[i] > maxWidth) { maxWidth = listViewColumnWidths[i]; maxIndex = i; } - } listViewColumnWidths[maxIndex]--; columnWidthsSum--; @@ -407,14 +379,13 @@ private void AddFilter(Window win) _filterField.TextChanged += (sender, e) => { - string? filterText = _filterField.Text?.ToString(); + var filterText = _filterField.Text; try { _filterErrorView.Text = string.Empty; _filterErrorView.SetNeedsDraw(); _applicationData!.Filter = filterText!; ApplyFilter(); - } catch (Exception ex) { @@ -435,22 +406,18 @@ private void AddHeaders(Window win, List gridHeaders) //Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails!.ListViewOffset, _gridViewDetails.ListViewColumnWidths), }; if (_applicationData!.MinUI) - { _header.Y = 0; - } else - { _header.Y = Pos.Bottom(_filterErrorView!); - } win.Add(_header); if (!_applicationData.MinUI) { - var headerLine = new Line() + var headerLine = new Line { X = MARGIN_LEFT, Y = Pos.Bottom(_header), - Width = Dim.Fill(MARGIN_LEFT), + Width = Dim.Fill(MARGIN_LEFT) }; win.Add(headerLine); } @@ -464,13 +431,9 @@ private void AddListView(Window win) X = MARGIN_LEFT }; if (!_applicationData!.MinUI) - { _listView.Y = Pos.Bottom(_filterLabel!) + 2; // 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; @@ -487,7 +450,6 @@ private void AddListView(Window win) 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. @@ -497,6 +459,5 @@ public void Dispose() // 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 From 4245236470e9d1f6abe8ce98d12a59e5c56c58c5 Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 14 Nov 2025 08:48:44 -0700 Subject: [PATCH 06/19] WIP: Starting to get SOT working. Add SOT launch config and update Esc shortcut description Added a new "SOT" launch configuration in `launchSettings.json` to run the `Show-ObjectTree` command using PowerShell 7-preview. Updated the "Esc" key shortcut description in `ShowObjectView.cs` from `"~ESC~ Close"` to `"Close"` for improved clarity. --- .../Properties/launchSettings.json | 6 ++++++ src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json index 74a3e81..4cd484c 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json @@ -22,6 +22,12 @@ "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView -MinUi }\"", "workingDirectory": "$(TargetDir)" + }, + "SOT": { + "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)" } } } \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs index 133dda1..1f4c393 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs @@ -117,7 +117,7 @@ public ShowObjectView(List rootObjects, ApplicationData applicationData) Add(filterErrorLabel); } - shortcuts.Add(new Shortcut(Key.Esc, "~ESC~ Close", () => Application.RequestStop())); + shortcuts.Add(new Shortcut(Key.Esc, "Close", () => Application.RequestStop())); var siCount = new Shortcut(Key.Empty, $"{rootObjects.Count} {elementDescription}", null); selectedStatusBarItem = new Shortcut(Key.Empty, string.Empty, null); From 5fd9efd693658908675befdf7cba2cee6ec4cbc9 Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 14 Nov 2025 10:00:26 -0700 Subject: [PATCH 07/19] Got SOT working + code cleanup Refactored `ConsoleGui` and `ShowObjectView` classes to improve readability, maintainability, and adherence to modern C# conventions. Key changes include: - Updated `FILTER_LABEL` to `_Filter` for UI consistency. - Simplified `ConsoleGui` initialization logic and removed redundant `SetNeedsDraw` call. - Rewrote `ShowObjectView` to use nullable fields, modern syntax, and dynamic regex validation. - Refactored `GetChildren` to handle nested objects with a `while` loop. - Improved `CachedMemberResult` and `CachedMemberResultElement` with better encapsulation and naming conventions. - Enhanced `RegexTreeViewTextFilter` with a primary constructor and better regex error handling. - Simplified `Run` method in `ShowObjectView` for cleaner application lifecycle management. - Fixed typos in comments and replaced redundant comments with concise explanations. - General cleanup using modern C# features like expression-bodied methods, pattern matching, and `null`-coalescing operators. --- .../ConsoleGui.cs | 12 +- .../ShowObjectView.cs | 387 ++++++++---------- 2 files changed, 166 insertions(+), 233 deletions(-) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs index 941c2be..abe16cf 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs @@ -18,7 +18,7 @@ namespace OutGridView.Cmdlet; internal sealed class ConsoleGui : IDisposable { - private const string FILTER_LABEL = "Filter"; + private const string FILTER_LABEL = "_Filter"; // This adjusts the left margin of all controls private const int MARGIN_LEFT = 1; @@ -48,10 +48,9 @@ internal sealed class ConsoleGui : IDisposable public HashSet Start(ApplicationData applicationData) { _applicationData = applicationData; - // In Terminal.Gui v2, Application.Init() no longer accepts a driver parameter. - // Instead, use Application.ForceDriver to specify the driver. if (_applicationData.UseNetDriver) Application.ForceDriver = "NetDriver"; Application.Init(); + _gridViewDetails = new GridViewDetails { // If OutputMode is Single or Multiple, then we make items selectable. If we make them selectable, @@ -67,7 +66,7 @@ public HashSet Start(ApplicationData applicationData) var gridHeaders = _applicationData.DataTable.DataColumns.Select(c => c.Label).ToList(); // Copy the input DataTable into our master ListView source list; upon exit any items - // that are IsMarked are returned (if Outputmode is set) + // that are IsMarked are returned (if OutputMode is set) _inputSource = LoadData(); if (!_applicationData.MinUI) @@ -85,8 +84,8 @@ public HashSet Start(ApplicationData applicationData) AddStatusBar(win, !_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. + // available. The ListView always shows a filtered version of _inputSource even if there is no + // actual filter. //ApplyFilter(); _listView?.SetFocus(); @@ -383,7 +382,6 @@ private void AddFilter(Window win) try { _filterErrorView.Text = string.Empty; - _filterErrorView.SetNeedsDraw(); _applicationData!.Filter = filterText!; ApplyFilter(); } diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs index 1f4c393..e280ab2 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs @@ -10,35 +10,31 @@ using System.Management.Automation; using System.Reflection; using System.Text.RegularExpressions; - using OutGridView.Models; - using Terminal.Gui.App; using Terminal.Gui.Drawing; +using Terminal.Gui.Input; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; -using Terminal.Gui.Input; namespace OutGridView.Cmdlet; internal sealed class ShowObjectView : Window, ITreeBuilder { - private readonly TreeView tree; - private readonly RegexTreeViewTextFilter filter; - private readonly Label filterErrorLabel; + private readonly TreeView? _tree; + private readonly View? _filterErrorView; public bool SupportsCanExpand => true; - private Shortcut? selectedStatusBarItem; - private StatusBar? statusBar; + private readonly Shortcut? _selectedShortcut; + private readonly StatusBar? _statusBar; public ShowObjectView(List rootObjects, ApplicationData applicationData) { - Title = applicationData.Title ?? "Show Object Tree"; + Title = applicationData.Title; Width = Dim.Fill(); Height = Dim.Fill(1); Modal = false; - if (applicationData.MinUI) { BorderStyle = LineStyle.None; @@ -47,257 +43,227 @@ public ShowObjectView(List rootObjects, ApplicationData applicationData) Height = Dim.Fill(); } - tree = new TreeView + var filterLabel = new Label { - Y = applicationData.MinUI ? 0 : 2, - Width = Dim.Fill(), - Height = Dim.Fill(), + Text = "_Filter:", + X = 1 }; - tree.TreeBuilder = this; - tree.AspectGetter = this.AspectGetter; - tree.SelectionChanged += this.SelectionChanged; - - // In Terminal.Gui v2, the default keybindings are different - // No need to clear ExpandAll keybinding - 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"); - } - - var shortcuts = new List(); - - 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 + var filterTextField = new TextField { Text = applicationData.Filter ?? string.Empty, - X = Pos.Right(lblFilter), + X = Pos.Right(filterLabel) + 1, Width = Dim.Fill(1) }; - tbFilter.CursorPosition = tbFilter.Text.Length; + filterTextField.CursorPosition = filterTextField.Text.Length; - tbFilter.TextChanged += (sender, e) => + _filterErrorView = new Label { - filter.Text = tbFilter.Text?.ToString() ?? string.Empty; + SchemeName = "Error", + X = Pos.Right(filterLabel) + 1, + Y = Pos.Top(filterLabel) + 1, + Width = Dim.Width(filterTextField), + Height = Dim.Auto(DimAutoStyle.Text) }; - - filterErrorLabel = new Label + _tree = new TreeView { - Text = string.Empty, - X = Pos.Right(lblFilter) + 1, - Y = Pos.Top(lblFilter) + 1, - Width = Dim.Fill() - lblFilter.Text!.Length + Y = Pos.Bottom(_filterErrorView), + Width = Dim.Fill(), + Height = Dim.Fill() }; + _tree.TreeBuilder = this; + _tree.AspectGetter = AspectGetter; + _tree.SelectionChanged += SelectionChanged; - if (!applicationData.MinUI) + var regexFilter = new RegexTreeViewTextFilter(this, _tree) { - Add(lblFilter); - Add(tbFilter); - Add(filterErrorLabel); - } + Text = applicationData.Filter ?? string.Empty + }; + _tree.Filter = regexFilter; + + if (rootObjects.Count > 0) + _tree.AddObjects(rootObjects); + else + _tree.AddObject("No Objects"); + filterTextField.TextChanged += OnFilterTextFieldOnTextChanged; + + 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 siCount = new Shortcut(Key.Empty, $"{rootObjects.Count} {elementDescription}", null); - selectedStatusBarItem = new Shortcut(Key.Empty, string.Empty, null); - shortcuts.Add(siCount); - shortcuts.Add(selectedStatusBarItem); + Shortcut 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)); + $"{Application.Driver} v{FileVersionInfo.GetVersionInfo(Assembly.GetAssembly(typeof(Application))!.Location).ProductVersion}", + null)); } - statusBar = new StatusBar(shortcuts); - statusBar.Visible = !applicationData.MinUI; - Application.Top!.Add(statusBar); + _statusBar = new StatusBar(shortcuts); + _statusBar.Visible = !applicationData.MinUI; - Add(tree); -} + if (!applicationData.MinUI) + { + Add(filterLabel); + Add(filterTextField); + Add(_filterErrorView); + } - internal void SetRegexError(string error) - { - if (string.Equals(error, filterErrorLabel.Text?.ToString(), StringComparison.Ordinal)) + Add(_tree); + Add(_statusBar); + return; + + void OnFilterTextFieldOnTextChanged(object? sender, EventArgs e) { - return; + 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; } - filterErrorLabel.Text = error; - filterErrorLabel.SchemeName = "Error"; - } + } + + internal void SetRegexError(string error) + { + if (string.Equals(error, _filterErrorView?.Text, StringComparison.Ordinal)) return; + _filterErrorView?.Text = error; + } private void SelectionChanged(object? sender, SelectionChangedEventArgs e) { var selectedValue = e.NewValue; - if (selectedValue is CachedMemberResult cmr) - { - selectedValue = cmr.Value; - } + if (selectedValue is CachedMemberResult cmr) selectedValue = cmr.Value; - if (selectedValue != null && selectedStatusBarItem != null) - { - selectedStatusBarItem.Title = selectedValue.GetType().Name; - } - else if (selectedStatusBarItem != null) - { - selectedStatusBarItem.Title = string.Empty; - } + if (selectedValue != null && _selectedShortcut != null) + _selectedShortcut.Title = selectedValue.GetType().Name; + else + _selectedShortcut?.Title = string.Empty; - statusBar?.SetNeedsDraw(); + _statusBar?.SetNeedsDraw(); } - private string? AspectGetter(object toRender) + 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; - } + 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); - } + private bool IsRootObject(object o) => _tree!.Objects.Contains(o); public bool CanExpand(object toExpand) { - if (toExpand is CachedMemberResult p) - { - return IsBasicType(p?.Value); - } + 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; - } + private static bool IsBasicType(object? value) => + value != null && value is not string && !value.GetType().IsValueType; - public IEnumerable GetChildren(object forObject) + public IEnumerable GetChildren(object? forObject) { - if (forObject == null || !this.CanExpand(forObject)) + while (true) { - return Enumerable.Empty(); - } + if (forObject == null || !CanExpand(forObject)) return []; - if (forObject is CachedMemberResult p) - { - if (p.IsCollection) + if (forObject is CachedMemberResult p) { - return p.Elements ?? Enumerable.Empty(); + if (p.IsCollection) return p.Elements ?? Enumerable.Empty(); + + forObject = p.Value; + continue; } - return GetChildren(p.Value); - } + if (forObject is CachedMemberResultElement e) + { + forObject = e.Value; + continue; + } - if (forObject is CachedMemberResultElement e) - { - return GetChildren(e.Value); - } + var children = new List(); - 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)); - foreach (var member in forObject.GetType().GetMembers(BindingFlags.Instance | BindingFlags.Public).OrderBy(m => m.Name)) - { - if (member is PropertyInfo prop) + if (member is FieldInfo field) children.Add(new CachedMemberResult(forObject, field)); + } + + try { - children.Add(new CachedMemberResult(forObject, prop)); + children.AddRange(GetExtraChildren(forObject)); } - if (member is FieldInfo field) + catch (Exception) { - children.Add(new CachedMemberResult(forObject, field)); + // Extra children unavailable, possibly security or IO exceptions enumerating children etc } - } - try - { - children.AddRange(GetExtraChildren(forObject)); - } - catch (Exception) - { - // Extra children unavailable, possibly security or IO exceptions enumerating children etc + return children; } - - return children; } private static IEnumerable GetExtraChildren(object forObject) { if (forObject is DirectoryInfo dir) - { foreach (var c in dir.EnumerateFileSystemInfos()) - { yield return c; - } - } } internal static void Run(List objects, ApplicationData applicationData) { // In Terminal.Gui v2, Application.Init() no longer accepts a driver parameter. // Instead, use Application.ForceDriver to specify the driver. - if (applicationData.UseNetDriver) - { - Application.ForceDriver = "NetDriver"; - } + if (applicationData.UseNetDriver) Application.ForceDriver = "NetDriver"; Application.Init(); Window? window = null; try { window = new ShowObjectView(objects.Select(p => p.BaseObject).ToList(), applicationData); - Application.Top!.Add(window); - Application.Run(); + Application.Run(window); } finally { - Application.Shutdown(); window?.Dispose(); + Application.Shutdown(); } } } -sealed class CachedMemberResultElement +internal sealed class CachedMemberResultElement { public int Index; public object? Value; - private readonly string representation; + private readonly string _representation; public CachedMemberResultElement(object? value, int index) { @@ -306,30 +272,28 @@ public CachedMemberResultElement(object? value, int index) try { - representation = Value?.ToString() ?? "Null"; + _representation = Value?.ToString() ?? "Null"; } catch (Exception) { - Value = representation = "Unavailable"; + Value = _representation = "Unavailable"; } } - public override string ToString() - { - return $"[{Index}]: {representation}]"; - } + + public override string ToString() => $"[{Index}]: {_representation}]"; } -sealed class CachedMemberResult +internal sealed class CachedMemberResult { - public MemberInfo Member; - public object? Value; - public object Parent; - private readonly string representation; - private List? valueAsList; + public MemberInfo Member { get; set; } + public object? Value { get; set; } + public object Parent { get; set; } + private readonly string? _representation; + private List? _valueAsList; - public bool IsCollection => valueAsList != null; - public IReadOnlyCollection? Elements => valueAsList?.AsReadOnly(); + public bool IsCollection => _valueAsList != null; + public IReadOnlyCollection? Elements => _valueAsList?.AsReadOnly(); public CachedMemberResult(object parent, MemberInfo mem) { @@ -339,39 +303,27 @@ public CachedMemberResult(object parent, MemberInfo 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(); + _representation = ValueToString(); } catch (Exception) { - Value = representation = "Unavailable"; + Value = _representation = "Unavailable"; } } private string? ValueToString() { - if (Value == null) - { - return "Null"; - } + if (Value == null) return "Null"; try { - if (IsCollectionOfKnownTypeAndSize(out Type? elementType, out int size)) - { + if (IsCollectionOfKnownTypeAndSize(out var elementType, out var size)) return $"{elementType!.Name}[{size}]"; - } } catch (Exception) { @@ -387,15 +339,11 @@ private bool IsCollectionOfKnownTypeAndSize(out Type? elementType, out int size) elementType = null; size = 0; - if (Value == null || Value is string) - { - - return false; - } + if (Value is null or string) return false; - if (Value is IEnumerable ienumerable) + if (Value is IEnumerable enumerable) { - var list = ienumerable.Cast().ToList(); + var list = enumerable.Cast().ToList(); var types = list.Where(v => v != null).Select(v => v!.GetType()).Distinct().ToArray(); @@ -404,7 +352,7 @@ private bool IsCollectionOfKnownTypeAndSize(out Type? elementType, out int size) elementType = types[0]; size = list.Count; - valueAsList = list.Select((e, i) => new CachedMemberResultElement(e, i)).ToList(); + _valueAsList = list.Select((e, i) => new CachedMemberResultElement(e, i)).ToList(); return true; } } @@ -412,31 +360,21 @@ private bool IsCollectionOfKnownTypeAndSize(out Type? elementType, out int size) return false; } - public override string ToString() - { - return Member.Name + ": " + representation; - } + public override string ToString() => Member.Name + ": " + _representation; } -sealed class RegexTreeViewTextFilter : ITreeViewFilter +internal sealed class RegexTreeViewTextFilter(ShowObjectView parent, TreeView forTree) : ITreeViewFilter { - private readonly ShowObjectView parent; - readonly TreeView _forTree; + private readonly TreeView _forTree = forTree ?? throw new ArgumentNullException(nameof(forTree)); - public RegexTreeViewTextFilter(ShowObjectView parent, TreeView forTree) - { - this.parent = parent; - _forTree = forTree ?? throw new ArgumentNullException(nameof(forTree)); - } - - private string text = string.Empty; + private string _text = string.Empty; public string Text { - get { return text; } + get => _text; set { - text = value; + _text = value; RefreshTreeView(); } } @@ -449,22 +387,19 @@ private void RefreshTreeView() public bool IsMatch(object model) { - if (string.IsNullOrWhiteSpace(Text)) - { - return true; - } - - parent.SetRegexError(string.Empty); + if (string.IsNullOrWhiteSpace(Text)) return true; var modelText = _forTree.AspectGetter(model); try { - return Regex.IsMatch(modelText ?? string.Empty, text, RegexOptions.IgnoreCase); + var isMatch = Regex.IsMatch(modelText ?? string.Empty, Text, RegexOptions.IgnoreCase); + parent.SetRegexError(string.Empty); + return isMatch; } catch (RegexParseException e) { parent.SetRegexError(e.Message); - return true; + return false; } } -} +} \ No newline at end of file From 1052c3999a789a68ce3911366749aa085fe6c25c Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 14 Nov 2025 10:55:06 -0700 Subject: [PATCH 08/19] Code modernization and cleanup. Refactored namespaces to align with `Microsoft.PowerShell` conventions, replacing `OutGridView.Cmdlet` and `OutGridView.Models`. Updated the module version to `0.9.0` to reflect significant changes. Improved code readability and maintainability by adding XML documentation, enforcing consistent naming conventions, and removing redundant code. Enhanced null safety with nullable annotations and null checks. Refactored key classes (`ConsoleGui`, `GridViewDataSource`, `GridViewDetails`, `GridViewHelpers`, `OutConsoleGridviewCmdletCommand`, `ShowObjectTreeCmdletCommand`, etc.) to simplify logic, improve error handling, and ensure modern C# practices. Updated `launchSettings.json` to rename the `SOT` profile to `SHOT` for consistency with the `Show-ObjectTree` cmdlet. Enhanced serialization logic in `Serializers.cs` and improved type handling in `TypeGetter.cs`. These changes improve the maintainability, safety, and usability of the module while aligning with modern development standards. --- GraphicalTools.sln.DotSettings | 7 +- .../ConsoleGui.cs | 39 +-- .../GridViewDataSource.cs | 90 ++++- .../GridViewDetails.cs | 33 +- .../GridViewHelpers.cs | 35 +- .../GridViewRow.cs | 36 +- .../Microsoft.PowerShell.ConsoleGuiTools.psd1 | 2 +- .../OutConsoleGridviewCmdletCommand.cs | 326 +++++++++--------- .../Properties/launchSettings.json | 2 +- .../ShowObjectTreeCmdletCommand.cs | 273 ++++++++------- .../ShowObjectView.cs | 6 +- .../TypeGetter.cs | 306 ++++++++-------- .../ApplicationData.cs | 69 +++- .../DataTable.cs | 38 +- .../DataTableColumn.cs | 90 +++-- .../DataTableRow.cs | 130 +++++-- .../OutputModeOptions.cs | 39 ++- .../Serializers.cs | 17 +- 18 files changed, 897 insertions(+), 641 deletions(-) diff --git a/GraphicalTools.sln.DotSettings b/GraphicalTools.sln.DotSettings index c8249bf..40a04f5 100644 --- a/GraphicalTools.sln.DotSettings +++ b/GraphicalTools.sln.DotSettings @@ -1,2 +1,7 @@  - <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> \ No newline at end of file + 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/ConsoleGui.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs index abe16cf..d5cb60d 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs @@ -7,14 +7,14 @@ using System.Linq; using System.Reflection; using System.Text.RegularExpressions; -using OutGridView.Models; +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 OutGridView.Cmdlet; +namespace Microsoft.PowerShell.ConsoleGuiTools; internal sealed class ConsoleGui : IDisposable { @@ -63,7 +63,7 @@ public HashSet Start(ApplicationData applicationData) var win = CreateTopLevelWindow(); // Create the headers and calculate column widths based on the DataTable - var gridHeaders = _applicationData.DataTable.DataColumns.Select(c => c.Label).ToList(); + var gridHeaders = _applicationData.DataTable?.DataColumns.Select(c => c.Label).ToList(); // Copy the input DataTable into our master ListView source list; upon exit any items // that are IsMarked are returned (if OutputMode is set) @@ -74,14 +74,14 @@ public HashSet Start(ApplicationData applicationData) // Add Filter UI AddFilter(win); // Add Header UI - AddHeaders(win, gridHeaders); + AddHeaders(win); } // Add ListView AddListView(win); // Status bar is where our key-bindings are handled - AddStatusBar(win, !_applicationData.MinUI); + AddStatusBar(win); // We *always* apply a filter, even if the -Filter parameter is not set or Filtering is not // available. The ListView always shows a filtered version of _inputSource even if there is no @@ -126,7 +126,7 @@ private GridViewDataSource LoadData() if (_applicationData == null) return new GridViewDataSource(items); - for (var i = 0; i < _applicationData.DataTable.Data.Count; i++) + for (var i = 0; i < _applicationData.DataTable!.Data.Count; i++) { var dataTableRow = _applicationData.DataTable.Data[i]; var valueList = new List(); @@ -155,7 +155,7 @@ private void UpdateDisplayStrings(GridViewDataSource? source) foreach (var gvr in source.GridViewRowList) { var valueList = new List(); - var dataTableRow = _applicationData!.DataTable.Data[gvr.OriginalIndex]; + var dataTableRow = _applicationData!.DataTable!.Data[gvr.OriginalIndex]; foreach (var dataTableColumn in _applicationData.DataTable.DataColumns) { var dataValue = dataTableRow.Values[dataTableColumn.ToString()].DisplayValue; @@ -238,7 +238,7 @@ private Window CreateTopLevelWindow() return win; } - private void AddStatusBar(Window win, bool visible) + private void AddStatusBar(Window win) { var shortcuts = new List(); if (_applicationData!.OutputMode != OutputModeOption.None) @@ -299,15 +299,16 @@ private void AddStatusBar(Window win, bool visible) win.Add(new StatusBar(shortcuts)); } - private void CalculateColumnWidths(List gridHeaders) + private void CalculateColumnWidths(List? gridHeaders) { + if (gridHeaders == null) return; _gridViewDetails!.ListViewColumnWidths = new int[gridHeaders.Count]; var listViewColumnWidths = _gridViewDetails.ListViewColumnWidths; for (var 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) + foreach (var row in _applicationData!.DataTable!.Data) { var index = 0; @@ -370,19 +371,19 @@ private void AddFilter(Window win) Text = string.Empty, X = Pos.Right(_filterLabel) + 1, Y = Pos.Top(_filterLabel) + 1, - Width = Dim.Fill() - _filterLabel.Text!.Length, + Width = Dim.Fill() - _filterLabel.Text.Length, // This enables the height to go 0, and the view to disappear when there is no error Height = Dim.Auto(DimAutoStyle.Text), SchemeName = "Error" }; - _filterField.TextChanged += (sender, e) => + _filterField.TextChanged += (_, _) => { var filterText = _filterField.Text; try { _filterErrorView.Text = string.Empty; - _applicationData!.Filter = filterText!; + _applicationData!.Filter = filterText; ApplyFilter(); } catch (Exception ex) @@ -397,16 +398,10 @@ private void AddFilter(Window win) _filterField.CursorPosition = _filterField.Text.Length; } - private void AddHeaders(Window win, List gridHeaders) + private void AddHeaders(Window win) { - _header = new Label - { - //Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails!.ListViewOffset, _gridViewDetails.ListViewColumnWidths), - }; - if (_applicationData!.MinUI) - _header.Y = 0; - else - _header.Y = Pos.Bottom(_filterErrorView!); + _header = new Label(); + _header.Y = _applicationData!.MinUI ? 0 : Pos.Bottom(_filterErrorView!); win.Add(_header); if (!_applicationData.MinUI) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs index 04c0b99..a85350f 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs @@ -5,43 +5,83 @@ using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; -using System.Text; using Terminal.Gui.App; -using Terminal.Gui.Drivers; -using Terminal.Gui.Text; using Terminal.Gui.Views; -namespace OutGridView.Cmdlet; +namespace Microsoft.PowerShell.ConsoleGuiTools; -internal sealed class GridViewDataSource : IListDataSource, IDisposable +/// +/// Provides a data source implementation for the grid view that manages rows and supports marking and rendering. +/// +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) { GridViewRowList = itemList; } + /// + /// 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, line); var driver = Application.Driver; var row = GridViewRowList[item]; - driver!.AddStr(row.DisplayString); - + driver!.AddStr(row.DisplayString ?? string.Empty); } + /// + /// 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; @@ -54,22 +94,38 @@ public void SetMark(int item, bool value) MarkChanged?.Invoke(this, args); } + /// + /// 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; } } + /// + /// Occurs when a row's marked state changes. + /// public event EventHandler? MarkChanged; - public IList ToList() - { - return GridViewRowList; - } - + /// + /// 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 d566b59..cdae77d 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs @@ -6,27 +6,43 @@ 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) + /// + /// Formats a list of strings into a single padded string for display in the grid view. + /// Each string is padded to fit its corresponding column width, with newlines replaced by encoded representations. + /// + /// The list of strings to format, one per column. + /// The left offset (padding) to add before the first column. + /// The width of each column. If null, an empty string is returned. + /// A formatted string with columns padded and separated by spaces, or an empty string if column widths are not provided. + public static string GetPaddedString(List? strings, int offset, int[]? listViewColumnWidths) { if (listViewColumnWidths is null) { @@ -39,6 +55,7 @@ public static string GetPaddedString(List strings, int offset, int[]? li builder.Append(string.Empty.PadRight(offset)); } + if (strings == null) return builder.ToString(); for (int i = 0; i < strings.Count; i++) { if (i > 0) @@ -49,11 +66,11 @@ public static string GetPaddedString(List strings, int offset, int[]? li // 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 the string doesn't fit in the column, append an ellipsis. if (strings[i].Length > listViewColumnWidths[i]) { builder.Append(strings[i], 0, listViewColumnWidths[i] - 3); @@ -68,4 +85,4 @@ public static string GetPaddedString(List strings, int offset, int[]? li 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/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/OutConsoleGridviewCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs index 94bb7e1..49c64d7 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs @@ -6,182 +6,186 @@ using System.Collections.Generic; 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 = new(); + private readonly ConsoleGui _consoleGui = 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 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 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 typeGetter = new TypeGetter(this); + var dataTable = typeGetter.CastObjectsToTableView(_psObjects); + 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() + 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 (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() + { + _consoleGui.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json index 4cd484c..cbdf4d6 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json @@ -23,7 +23,7 @@ "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView -MinUi }\"", "workingDirectory": "$(TargetDir)" }, - "SOT": { + "SHOT": { "commandName": "Executable", "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Show-ObjectTree }\"", diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs index 5b2f95d..8f241a9 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs @@ -6,151 +6,158 @@ using System.Collections.Generic; 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); - } + Title = Title ?? "Show-ObjectTree", + Filter = Filter, + MinUI = MinUI, + UseNetDriver = UseNetDriver, + Debug = Debug, + ModuleVersion = MyInvocation.MyCommand.Version.ToString() + }; + + ShowObjectView.Run(_psObjects, 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/ShowObjectView.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs index e280ab2..993fc91 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs @@ -10,14 +10,14 @@ using System.Management.Automation; using System.Reflection; using System.Text.RegularExpressions; -using OutGridView.Models; +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 OutGridView.Cmdlet; +namespace Microsoft.PowerShell.ConsoleGuiTools; internal sealed class ShowObjectView : Window, ITreeBuilder { @@ -30,7 +30,7 @@ internal sealed class ShowObjectView : Window, ITreeBuilder public ShowObjectView(List rootObjects, ApplicationData applicationData) { - Title = applicationData.Title; + Title = applicationData.Title ?? "Show-ObjectView"; Width = Dim.Fill(); Height = Dim.Fill(1); Modal = false; diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs index 6f3f643..2c2190d 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs @@ -1,198 +1,216 @@ // 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 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. +/// +/// The PSCmdlet instance used to invoke PowerShell commands. +public class TypeGetter(PSCmdlet cmdlet) { - public class TypeGetter + /// + /// Gets the format view definition for the specified PowerShell object by querying the format data. + /// + /// The PowerShell object to get the format view definition for. + /// The format view definition if found; otherwise, . + public FormatViewDefinition? GetFormatViewDefinitionForObject(PSObject obj) { - private PSCmdlet _cmdlet; - - public TypeGetter(PSCmdlet cmdlet) - { - _cmdlet = cmdlet; - } - public FormatViewDefinition GetFormatViewDefinitionForObject(PSObject obj) - { - var typeName = obj.BaseObject.GetType().FullName; - - var types = _cmdlet.InvokeCommand.InvokeScript(@"Microsoft.PowerShell.Utility\Get-FormatData " + typeName).ToList(); - - //No custom type definitions found - try the PowerShell specific format data - if (types == null || types.Count == 0) - { - types = _cmdlet.InvokeCommand - .InvokeScript(@"Microsoft.PowerShell.Utility\Get-FormatData -PowerShellVersion $PSVersionTable.PSVersion " + typeName).ToList(); + var typeName = obj.BaseObject.GetType().FullName; - if (types == null || types.Count == 0) - { - return null; - } - } + var types = cmdlet.InvokeCommand.InvokeScript(@"Microsoft.PowerShell.Utility\Get-FormatData " + typeName) + .ToList(); - var extendedTypeDefinition = types[0].BaseObject as ExtendedTypeDefinition; + // No custom type definitions found - try the PowerShell specific format data + if (types.Count == 0) + { + types = cmdlet.InvokeCommand + .InvokeScript( + @"Microsoft.PowerShell.Utility\Get-FormatData -PowerShellVersion $PSVersionTable.PSVersion " + + typeName).ToList(); - return extendedTypeDefinition.FormatViewDefinition[0]; + if (types.Count == 0) return null; } - public static DataTableRow CastObjectToDataTableRow(PSObject ps, List dataColumns, int objectIndex) - { - Dictionary valuePairs = new Dictionary(); - - foreach (var dataColumn in dataColumns) - { - var expression = new PSPropertyExpression(ScriptBlock.Create(dataColumn.PropertyScriptAccessor)); + var extendedTypeDefinition = types[0].BaseObject as ExtendedTypeDefinition; - var result = expression.GetValues(ps).FirstOrDefault().Result; + return extendedTypeDefinition?.FormatViewDefinition[0]; + } - var stringValue = result?.ToString() ?? String.Empty; + /// + /// Converts a PowerShell object to a data table row with values extracted based on the specified columns. + /// + /// The PowerShell object to convert. + /// The list of columns defining which properties to extract. + /// The original index of the object in the source collection. + /// A containing the extracted values. + public static DataTableRow CastObjectToDataTableRow(PSObject ps, List dataColumns, int objectIndex) + { + var valuePairs = new Dictionary(); - var isDecimal = decimal.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat, out var decimalValue); + foreach (var dataColumn in dataColumns) + { + var expression = new PSPropertyExpression(ScriptBlock.Create(dataColumn.PropertyScriptAccessor)); - if (isDecimal) - { - valuePairs[dataColumn.ToString()] = new DecimalValue { DisplayValue = stringValue, SortValue = decimalValue }; - } - else - { - var stringDecorated = new StringDecorated(stringValue); - valuePairs[dataColumn.ToString()] = new StringValue { DisplayValue = stringDecorated.ToString(OutputRendering.PlainText) }; - } - } + var result = expression.GetValues(ps).FirstOrDefault()?.Result; - return new DataTableRow(valuePairs, objectIndex); - } + var stringValue = result?.ToString() ?? string.Empty; - private static void SetTypesOnDataColumns(List dataTableRows, List dataTableColumns) - { - var dataRows = dataTableRows.Select(x => x.Values); + var isDecimal = decimal.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat, + out var decimalValue); - foreach (var dataColumn in dataTableColumns) + if (isDecimal) { - dataColumn.StringType = typeof(decimal).FullName; + valuePairs[dataColumn.ToString()] = new DecimalValue + { DisplayValue = stringValue, SortValue = decimalValue }; } - - //If every value in a column could be a decimal, assume that it is supposed to be a decimal - foreach (var dataRow in dataRows) + else { - foreach (var dataColumn in dataTableColumns) - { - if (!(dataRow[dataColumn.ToString()] is DecimalValue)) - { - dataColumn.StringType = typeof(string).FullName; - } - } + var stringDecorated = new StringDecorated(stringValue); + valuePairs[dataColumn.ToString()] = new StringValue + { DisplayValue = stringDecorated.ToString(OutputRendering.PlainText) }; } } - private List GetDataColumnsForObject(List psObjects) - { - var dataColumns = new List(); + return new DataTableRow(valuePairs, objectIndex); + } + /// + /// Sets the data type on each column based on the values in the data rows. + /// If all values in a column can be parsed as decimal, the column type is set to decimal; otherwise, it's set to + /// string. + /// + /// The list of data table rows to analyze. + /// The list of data table columns to update with type information. + private static void SetTypesOnDataColumns(List dataTableRows, List dataTableColumns) + { + var dataRows = dataTableRows.Select(x => x.Values); - foreach (PSObject obj in psObjects) - { - var labels = new List(); + foreach (var dataColumn in dataTableColumns) dataColumn.StringType = typeof(decimal).FullName; + + // 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) + if (!(dataRow[dataColumn.ToString()] is DecimalValue)) + dataColumn.StringType = typeof(string).FullName; + } + + /// + /// Retrieves the column definitions for the specified PowerShell objects based on their format view definitions or + /// properties. + /// + /// The list of PowerShell objects to analyze. + /// A distinct list of data table columns. + private List GetDataColumnsForObject(List psObjects) + { + var dataColumns = new List(); + + foreach (var obj in psObjects) + { + List labels; - FormatViewDefinition fvd = GetFormatViewDefinitionForObject(obj); + var fvd = GetFormatViewDefinitionForObject(obj); - var propertyAccessors = new List(); + List propertyAccessors; - if (fvd == null) + if (fvd == null) + { + if (PSObjectIsPrimitive(obj)) { - if (PSObjectIsPrimitive(obj)) - { - labels = new List { obj.BaseObject.GetType().Name }; - propertyAccessors = new List { "$_" }; - } - else - { - labels = obj.Properties.Select(x => x.Name).ToList(); - propertyAccessors = obj.Properties.Select(x => $"$_.\"{x.Name}\"").ToList(); - } + labels = [obj.BaseObject.GetType().Name]; + propertyAccessors = ["$_"]; } else { - var tableControl = fvd.Control as TableControl; - - var definedColumnLabels = tableControl.Headers.Select(x => x.Label); - - var displayEntries = tableControl.Rows[0].Columns.Select(x => x.DisplayEntry); - - var propertyLabels = displayEntries.Select(x => x.Value); - - //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(); + labels = obj.Properties.Select(x => x.Name).ToList(); + propertyAccessors = obj.Properties.Select(x => $"$_.\"{x.Name}\"").ToList(); } + } + else + { + var tableControl = fvd.Control as TableControl; + + var definedColumnLabels = tableControl?.Headers.Select(x => x.Label); - for (var i = 0; i < labels.Count; i++) + var displayEntries = tableControl?.Rows[0].Columns.Select(x => x.DisplayEntry); + + var enumerable = displayEntries as DisplayEntry[] ?? displayEntries!.ToArray(); + var propertyLabels = enumerable.Select(x => x.Value); + + // Use the TypeDefinition Label if available otherwise just use the property name as a label + labels = (definedColumnLabels ?? []).Zip(propertyLabels, (definedColumnLabel, propertyLabel) => { - dataColumns.Add(new DataTableColumn(labels[i], propertyAccessors[i])); - } + if (string.IsNullOrEmpty(definedColumnLabel)) return propertyLabel; + + return definedColumnLabel; + }).ToList(); + + propertyAccessors = enumerable.Select(x => x.ValueType == DisplayEntryValueType.Property + ? $"$_.\"{x.Value}\"" + : + // Otherwise return access script + x.Value).ToList(); } - return dataColumns.Distinct().ToList(); - } - public DataTable CastObjectsToTableView(List psObjects) - { - List objectFormats = psObjects.Select(GetFormatViewDefinitionForObject).ToList(); + dataColumns.AddRange(labels.Select((t, i) => new DataTableColumn(t, propertyAccessors[i]))); + } - var dataTableColumns = GetDataColumnsForObject(psObjects); + return dataColumns.Distinct().ToList(); + } - List dataTableRows = new List(); - for (var i = 0; i < objectFormats.Count; i++) - { - var dataTableRow = CastObjectToDataTableRow(psObjects[i], dataTableColumns, i); - dataTableRows.Add(dataTableRow); - } + /// + /// 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. + /// A containing the columns and rows extracted from the PowerShell objects. + public DataTable CastObjectsToTableView(List psObjects) + { + var objectFormats = psObjects.Select(GetFormatViewDefinitionForObject).ToList(); - SetTypesOnDataColumns(dataTableRows, dataTableColumns); + var dataTableColumns = GetDataColumnsForObject(psObjects); - return new DataTable(dataTableColumns, dataTableRows); + var dataTableRows = new List(); + for (var i = 0; i < objectFormats.Count; i++) + { + var dataTableRow = CastObjectToDataTableRow(psObjects[i], dataTableColumns, i); + dataTableRows.Add(dataTableRow); } + SetTypesOnDataColumns(dataTableRows, dataTableColumns); - //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) - { - var psBaseType = ps.BaseObject.GetType(); + return new DataTable(dataTableColumns, dataTableRows); + } - return psBaseType.IsPrimitive || psBaseType.IsEnum || additionalPrimitiveTypes.Contains(psBaseType.FullName); - } + /// + /// 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 in the PowerShell context. + /// + /// The PowerShell object to check. + /// if the object is a primitive type; otherwise, . + private static bool PSObjectIsPrimitive(PSObject ps) + { + var psBaseType = ps.BaseObject.GetType(); + + return psBaseType.IsPrimitive || psBaseType.IsEnum || ADDITIONAL_PRIMITIVE_TYPES.Contains(psBaseType.FullName!); } -} +} \ 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..09b0987 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs @@ -1,24 +1,55 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System; -using System.Collections.Generic; +namespace Microsoft.PowerShell.OutGridView.Models; -namespace 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 title displayed in the Out-GridView window. + /// + public string? Title { 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 filter text to apply to the data table. + /// + 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 the data table containing the columns and rows to display. + /// + public DataTable? DataTable { 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 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..1f432e3 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/DataTableColumn.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/DataTableColumn.cs @@ -2,39 +2,67 @@ // 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 => 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 the script accessor used to retrieve the property value for this column. + /// + public string PropertyScriptAccessor { get; } = propertyScriptAccessor; + + /// + /// 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) { - [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; - } - - //Distinct column defined by Label, Prop Accessor - public override bool Equals(object obj) - { - DataTableColumn b = obj as DataTableColumn; - return b.Label == Label && b.PropertyScriptAccessor == PropertyScriptAccessor; - } - public override int GetHashCode() - { - return Label.GetHashCode() + PropertyScriptAccessor.GetHashCode(); - } - public override string ToString() - { - //Needs to be encoded to embed safely in xaml - return Convert.ToBase64String(Encoding.UTF8.GetBytes(Label + PropertyScriptAccessor)); - } + 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..3e27a04 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/DataTableRow.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/DataTableRow.cs @@ -4,44 +4,98 @@ 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; } +} + +/// +/// 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; } + + /// + /// 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; } + + /// + /// 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/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); } From 9f4bc393f2791ac58dfe293df43fdfcabcfea444 Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 14 Nov 2025 10:58:58 -0700 Subject: [PATCH 09/19] Refactor ConsoleGui to OutConsoleGridView Refactored the `ConsoleGui` class into a new class named `OutConsoleGridView` to improve modularity and maintainability. The `ConsoleGui` class was removed, and its functionality was moved to `OutConsoleGridView`, which is now implemented in a separate file (`OutConsoleGridView.cs`). Updated all references to `ConsoleGui` in `OutConsoleGridViewCmdletCommand`: - Replaced `_consoleGui` with `_outConsoleGridView`. - Updated method calls to use `OutConsoleGridView` methods. - Updated the `Dispose` method to dispose of `_outConsoleGridView`. This change reorganizes the codebase for better readability and separation of concerns without introducing any functional changes. --- .../{ConsoleGui.cs => OutConsoleGridView.cs} | 2 +- .../OutConsoleGridviewCmdletCommand.cs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) rename src/Microsoft.PowerShell.ConsoleGuiTools/{ConsoleGui.cs => OutConsoleGridView.cs} (99%) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridView.cs similarity index 99% rename from src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs rename to src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridView.cs index d5cb60d..ee1f2d3 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridView.cs @@ -16,7 +16,7 @@ namespace Microsoft.PowerShell.ConsoleGuiTools; -internal sealed class ConsoleGui : IDisposable +internal sealed class OutConsoleGridView : IDisposable { private const string FILTER_LABEL = "_Filter"; diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs index 49c64d7..9359217 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs @@ -14,7 +14,7 @@ namespace Microsoft.PowerShell.ConsoleGuiTools; /// 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")] +[Cmdlet(VerbsData.Out, "OutConsoleGridView")] [Alias("ocgv")] public class OutConsoleGridViewCmdletCommand : PSCmdlet, IDisposable { @@ -24,7 +24,7 @@ public class OutConsoleGridViewCmdletCommand : PSCmdlet, IDisposable private const string ENVIRONMENT_NOT_SUPPORTED_FOR_GRID_VIEW = nameof(ENVIRONMENT_NOT_SUPPORTED_FOR_GRID_VIEW); private readonly List _psObjects = new(); - private readonly ConsoleGui _consoleGui = new(); + private readonly OutConsoleGridView _outConsoleGridView = new(); #endregion Properties @@ -171,7 +171,7 @@ protected override void EndProcessing() ModuleVersion = MyInvocation.MyCommand.Version.ToString() }; - var selectedIndexes = _consoleGui.Start(applicationData); + var selectedIndexes = _outConsoleGridView.Start(applicationData); foreach (var idx in selectedIndexes) { var selectedObject = _psObjects[idx]; @@ -185,7 +185,7 @@ protected override void EndProcessing() /// public void Dispose() { - _consoleGui.Dispose(); + _outConsoleGridView.Dispose(); GC.SuppressFinalize(this); } } \ No newline at end of file From ee84e8a378f5d2842e5248c91eede9316d83d07e Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 14 Nov 2025 10:59:59 -0700 Subject: [PATCH 10/19] Reverted cmdlet to ConsoleGridView The `Cmdlet` attribute for the `OutConsoleGridViewCmdletCommand` class was updated to rename the cmdlet from `Out-ConsoleGridView` to `ConsoleGridView`. This change simplifies the cmdlet name and may align with a new naming convention. The alias `[Alias("ocgv")]` remains unchanged, ensuring backward compatibility for users familiar with the shorthand. --- .../OutConsoleGridviewCmdletCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs index 9359217..791b4df 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs @@ -14,7 +14,7 @@ namespace Microsoft.PowerShell.ConsoleGuiTools; /// 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, "OutConsoleGridView")] +[Cmdlet(VerbsData.Out, "ConsoleGridView")] [Alias("ocgv")] public class OutConsoleGridViewCmdletCommand : PSCmdlet, IDisposable { From c22ca70f9ecd2014390092624a1f1f5023372d15 Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 14 Nov 2025 12:15:52 -0700 Subject: [PATCH 11/19] Massive OutConsoleGridView and ShowObjectTree refactor to prepare for unit testing. Refactored `OutConsoleGridView` and `ShowObjectTree` for improved maintainability and performance: - Introduced `OutGridViewWindow` and `ShowObjectTreeWindow` to encapsulate UI logic. - Added `CachedMemberResult` and `CachedMemberResultElement` for better object hierarchy handling. - Implemented `RegexTreeViewTextFilter` for regex-based filtering in tree views. Enhanced data handling and UI: - Replaced `DataTable` with direct `PSObjects` handling in `ApplicationData`. - Improved grid view filtering, column width calculations, and status bar shortcuts. - Enhanced tree view to display object hierarchies with filtering and status bar updates. Refactored `TypeGetter`: - Added caching for `FormatViewDefinition` lookups. - Made `CastObjectsToTableView` static for reusability. Updated dependencies and removed legacy code: - Updated `Directory.Packages.props` to use `System.Management.Automation`. - Removed redundant and outdated code from `OutConsoleGridView`. Bug fixes and documentation: - Fixed column width calculation issues and concurrency in `TypeGetter`. - Improved error handling for invalid regex patterns. - Added XML documentation for all new and modified classes. --- Directory.Packages.props | 5 +- .../CachedMemberResult.cs | 152 ++++++ .../CachedMemberResultElement.cs | 62 +++ .../OutConsoleGridView.cs | 446 ++--------------- .../OutConsoleGridviewCmdletCommand.cs | 9 +- .../OutGridViewWindow.cs | 473 ++++++++++++++++++ .../Properties/launchSettings.json | 2 +- .../RegexTreeViewTextFilter.cs | 81 +++ .../ShowObjectTreeCmdletCommand.cs | 4 +- .../ShowObjectTreeWindow.cs | 353 +++++++++++++ .../ShowObjectView.cs | 415 ++------------- .../TypeGetter.cs | 76 ++- .../ApplicationData.cs | 21 +- ...osoft.PowerShell.OutGridView.Models.csproj | 1 + 14 files changed, 1267 insertions(+), 833 deletions(-) create mode 100644 src/Microsoft.PowerShell.ConsoleGuiTools/CachedMemberResult.cs create mode 100644 src/Microsoft.PowerShell.ConsoleGuiTools/CachedMemberResultElement.cs create mode 100644 src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs create mode 100644 src/Microsoft.PowerShell.ConsoleGuiTools/RegexTreeViewTextFilter.cs create mode 100644 src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeWindow.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index d30fa55..fc10969 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,6 +2,7 @@ - + + - + \ 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/OutConsoleGridView.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridView.cs index ee1f2d3..87e1713 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridView.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridView.cs @@ -3,441 +3,53 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -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 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 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 View? _filterErrorView; - private Label? _header; - - 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 propagated back to _inputSource. - private GridViewDataSource? _listViewSource; private ApplicationData? _applicationData; - private GridViewDetails? _gridViewDetails; - public HashSet Start(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"; - 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 - }; - - var win = CreateTopLevelWindow(); - - // Create the headers and calculate column widths based on the DataTable - var gridHeaders = _applicationData.DataTable?.DataColumns.Select(c => c.Label).ToList(); - - // 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); - } - - // Add ListView - AddListView(win); - - // Status bar is where our key-bindings are handled - AddStatusBar(win); - // We *always* apply a filter, even if the -Filter parameter is not set or Filtering is not - // available. The ListView always shows a filtered version of _inputSource even if there is no - // actual filter. - //ApplyFilter(); - - _listView?.SetFocus(); - - win.SubViewLayout += OnWinSubViewLayout; - - // Run the GUI. - Application.Run(win); - win.Dispose(); - Application.Shutdown(); - - // Return results of selection if required. - var selectedIndexes = new HashSet(); - if (_cancelled) return selectedIndexes; - - // Return any items that were selected. - foreach (var gvr in _inputSource.GridViewRowList) - if (gvr.IsMarked) - selectedIndexes.Add(gvr.OriginalIndex); - - return selectedIndexes; - - void OnWinSubViewLayout(object? sender, EventArgs e) + var window = new OutGridViewWindow(_applicationData); + try { - CalculateColumnWidths(gridHeaders); - - if (_header is { }) - _header.Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails!.ListViewOffset, - _gridViewDetails.ListViewColumnWidths); - UpdateDisplayStrings(_listViewSource); - ApplyFilter(); + Application.Init(); + Application.Run(window); + _cancelled = window.Cancelled; + return window.GetSelectedIndexes(); } - } - - private GridViewDataSource LoadData() - { - var items = new List(); - if (_applicationData == null) - return new GridViewDataSource(items); - - for (var i = 0; i < _applicationData.DataTable!.Data.Count; i++) + finally { - var dataTableRow = _applicationData.DataTable.Data[i]; - var valueList = new List(); - foreach (var dataTableColumn in _applicationData.DataTable.DataColumns) - { - var dataValue = dataTableRow.Values[dataTableColumn.ToString()].DisplayValue; - valueList.Add(dataValue); - } - - var 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 - }); + window?.Dispose(); + Application.Shutdown(); } - - return new GridViewDataSource(items); - } - - private void UpdateDisplayStrings(GridViewDataSource? source) - { - if (source == null) return; - foreach (var gvr in source.GridViewRowList) - { - var valueList = new List(); - var dataTableRow = _applicationData!.DataTable!.Data[gvr.OriginalIndex]; - foreach (var dataTableColumn in _applicationData.DataTable.DataColumns) - { - var dataValue = dataTableRow.Values[dataTableColumn.ToString()].DisplayValue; - valueList.Add(dataValue); - } - - gvr.DisplayString = GridViewHelpers.GetPaddedString(valueList, 0, _gridViewDetails?.ListViewColumnWidths); - } - } - - 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. - - GridViewRow? selectedItem = null; - - if (_listViewSource != null) - { - // Get the item that is currently selected so we can restore selection after re-applying filter - selectedItem = _listViewSource?.GridViewRowList.ElementAtOrDefault(_listView?.SelectedItem ?? 0); - _listViewSource!.MarkChanged -= ListViewSource_MarkChanged; - _listViewSource = null; - } - - _inputSource ??= LoadData(); - - - if (_applicationData != null) - try - { - _listViewSource = new GridViewDataSource(GridViewHelpers.FilterData(_inputSource.GridViewRowList, - _applicationData.Filter ?? string.Empty)); - } - catch (RegexParseException ex) - { - _filterErrorView!.Text = ex.Message; - } - - _listViewSource?.MarkChanged += ListViewSource_MarkChanged; - _listView?.Source = _listViewSource; - - // Restore selection - find the previously selected item in the new filtered list - if (selectedItem is { } && _listViewSource != null) - { - var newIndex = - _listViewSource.GridViewRowList.FindIndex(i => i.OriginalIndex == selectedItem.OriginalIndex); - if (newIndex >= 0) _listView!.SelectedItem = newIndex; - } - - if (_listView?.SelectedItem == -1) _listView!.SelectedItem = 0; - } - - 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 - { - Title = _applicationData!.Title ?? "Out-ConsoleGridView" - }; - - if (_applicationData.MinUI) win.BorderStyle = LineStyle.None; - - return win; - } - - private void AddStatusBar(Window win) - { - var shortcuts = new List(); - if (_applicationData!.OutputMode != OutputModeOption.None) - // Use Key.Empty for SPACE with no delegate because ListView already - // handles SPACE - shortcuts.Add(new Shortcut(Key.Space, "Select Item", null)); - - if (_applicationData.OutputMode == OutputModeOption.Multiple) - { - shortcuts.Add(new Shortcut(Key.A.WithCtrl, "Select All", () => - { - _listView?.MarkAll(true); - _listView?.SetNeedsDraw(); // Bug in Terminal.Gui where MarkAll doesn't refresh display - })); - - // Ctrl-D is commonly used in GUIs for select-none - shortcuts.Add(new Shortcut(Key.D.WithCtrl, "Select None", () => - { - _listView?.MarkAll(false); - _listView?.SetNeedsDraw(); // Bug in Terminal.Gui where MarkAll doesn't refresh display - })); - } - - if (_applicationData.OutputMode != OutputModeOption.None) - shortcuts.Add(new Shortcut(Key.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) - // Toggle the mark on the currently selected item - 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)); - 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)); - } - - win.Add(new StatusBar(shortcuts)); - } - - private void CalculateColumnWidths(List? gridHeaders) - { - if (gridHeaders == null) return; - _gridViewDetails!.ListViewColumnWidths = new int[gridHeaders.Count]; - var listViewColumnWidths = _gridViewDetails.ListViewColumnWidths; - - for (var 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) - { - var 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; - var columnWidthsSum = listViewColumnWidths.Sum(); - while (columnWidthsSum >= _gridViewDetails.UsableWidth) - { - var maxWidth = 0; - var maxIndex = 0; - for (var 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 - { - 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 - }; - - // 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.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, - // This enables the height to go 0, and the view to disappear when there is no error - 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; - } - }; - - win.Add(_filterLabel, _filterField, _filterErrorView); - - _filterField.Text = _applicationData.Filter ?? string.Empty; - _filterField.CursorPosition = _filterField.Text.Length; - } - - private void AddHeaders(Window win) - { - _header = new Label(); - _header.Y = _applicationData!.MinUI ? 0 : Pos.Bottom(_filterErrorView!); - win.Add(_header); - - if (!_applicationData.MinUI) - { - var headerLine = new Line - { - X = MARGIN_LEFT, - Y = Pos.Bottom(_header), - Width = Dim.Fill(MARGIN_LEFT) - }; - win.Add(headerLine); - } - } - - private void AddListView(Window win) - { - _listView = new ListView - { - Source = _inputSource, - X = MARGIN_LEFT - }; - if (!_applicationData!.MinUI) - _listView.Y = Pos.Bottom(_filterLabel!) + 2; // 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.SelectedItem = 0; - - // ListView captures Ctrl-A (select all) - we handle this in the status bar - _listView.KeyBindings.Remove(Key.A.WithCtrl); - - win.Add(_listView); } public void Dispose() diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs index 791b4df..b330d28 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs @@ -4,6 +4,7 @@ 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; @@ -23,7 +24,7 @@ public class OutConsoleGridViewCmdletCommand : PSCmdlet, IDisposable 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 = new(); + private readonly List _psObjects = []; private readonly OutConsoleGridView _outConsoleGridView = new(); #endregion Properties @@ -156,22 +157,20 @@ protected override void EndProcessing() // Return if no objects if (_psObjects.Count == 0) return; - var typeGetter = new TypeGetter(this); - var dataTable = typeGetter.CastObjectsToTableView(_psObjects); var applicationData = new ApplicationData { + PSObjects = _psObjects.Cast().ToList(), 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 = _outConsoleGridView.Start(applicationData); + var selectedIndexes = _outConsoleGridView.Run(applicationData); foreach (var idx in selectedIndexes) { var selectedObject = _psObjects[idx]; diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs new file mode 100644 index 0000000..4e74ddf --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs @@ -0,0 +1,473 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +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 = 1; + private const int CHECK_WIDTH = 2; + + private Label? _filterLabel; + private TextField? _filterField; + private View? _filterErrorView; + private Label? _header; + private ListView? _listView; + private GridViewDataSource? _inputSource; + private GridViewDataSource? _listViewSource; + private readonly ApplicationData _applicationData; + private readonly GridViewDetails _gridViewDetails; + private readonly DataTable _dataTable; + + /// + /// 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 + if (_applicationData.PSObjects is { Count: > 0 }) + { + var typeGetter = new TypeGetter(); + var psObjects = _applicationData.PSObjects.Cast().ToList(); + _dataTable = TypeGetter.CastObjectsToTableView(psObjects); + } + else + { + _dataTable = new DataTable([], []); + } + + // Copy the input DataTable into our master ListView source list + _inputSource = LoadData(); + + if (!_applicationData.MinUI) + { + AddFilter(); + AddHeaders(); + } + + AddListView(); + AddStatusBar(); + + _listView?.SetFocus(); + } + + /// + /// 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 == null || _dataTable.Data.Count == 0) + return new GridViewDataSource(items); + + 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 dataValue = dataTableRow.Values[dataTableColumn.ToString()].DisplayValue; + valueList.Add(dataValue); + } + + var displayString = GridViewHelpers.GetPaddedString(valueList, 0, _gridViewDetails.ListViewColumnWidths); + + items.Add(new GridViewRow + { + DisplayString = displayString, + OriginalIndex = i + }); + } + + return new GridViewDataSource(items); + } + + /// + /// Updates the display strings for all rows in the specified data source based on current column widths. + /// + /// The data source containing rows to update. + private void UpdateDisplayStrings(GridViewDataSource? source) + { + if (source == null || _dataTable == null) return; + + foreach (var gvr in source.GridViewRowList) + { + var valueList = new List(); + var dataTableRow = _dataTable.Data[gvr.OriginalIndex]; + foreach (var dataTableColumn in _dataTable.DataColumns) + { + var dataValue = dataTableRow.Values[dataTableColumn.ToString()].DisplayValue; + valueList.Add(dataValue); + } + + gvr.DisplayString = GridViewHelpers.GetPaddedString(valueList, 0, _gridViewDetails.ListViewColumnWidths); + } + } + + #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 -= ListViewSource_MarkChanged; + _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; + } + + if (_listViewSource != null) + _listViewSource.MarkChanged += ListViewSource_MarkChanged; + + if (_listView != null) + _listView.Source = _listViewSource; + + if (selectedItem is { } && _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 ListViewSource_MarkChanged(object? s, GridViewDataSource.RowMarkedEventArgs a) + { + if (_inputSource != null) + _inputSource.GridViewRowList[a.Row.OriginalIndex].IsMarked = a.Row.IsMarked; + } + + #endregion + + #region User Actions + + /// + /// 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 column header label and separator line to the window. + /// + private void AddHeaders() + { + _header = new Label + { + Y = _applicationData.MinUI ? 0 : Pos.Bottom(_filterErrorView!) + }; + Add(_header); + + if (!_applicationData.MinUI) + { + var headerLine = new Line + { + X = MARGIN_LEFT, + Y = Pos.Bottom(_header), + Width = Dim.Fill(MARGIN_LEFT) + }; + Add(headerLine); + } + } + + /// + /// 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(_filterLabel!) + 2 : 1, + Width = Dim.Fill(1), + Height = Dim.Fill(), + AllowsMarking = _applicationData.OutputMode != OutputModeOption.None, + AllowsMultipleSelection = _applicationData.OutputMode == OutputModeOption.Multiple, + SelectedItem = 0 + }; + + _listView.KeyBindings.Remove(Key.A.WithCtrl); + + Add(_listView); + } + + /// + /// 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 Item", null)); + + if (_applicationData.OutputMode == OutputModeOption.Multiple) + { + shortcuts.Add(new Shortcut(Key.A.WithCtrl, "Select All", () => + { + _listView?.MarkAll(true); + _listView?.SetNeedsDraw(); + })); + + shortcuts.Add(new Shortcut(Key.D.WithCtrl, "Select 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)); + 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)); + } + + Add(new StatusBar(shortcuts)); + } + + #endregion + + #region Layout Calculation + + + /// + /// Handles layout of subviews by calculating column widths and applying the current filter. + /// + /// The layout event arguments. + protected override void OnSubViewLayout(LayoutEventArgs args) + { + // Create the headers and calculate column widths based on the DataTable + var gridHeaders = _dataTable?.DataColumns.Select(c => c.Label).ToList(); + + CalculateColumnWidths(gridHeaders); + + if (_header is { }) + _header.Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails.ListViewOffset, + _gridViewDetails.ListViewColumnWidths); + UpdateDisplayStrings(_listViewSource); + ApplyFilter(); + base.OnSubViewLayout(args); + } + + /// + /// Calculates optimal column widths based on header and data content, fitting within the available screen width. + /// + /// The column headers for the grid. + private void CalculateColumnWidths(List? gridHeaders) + { + if (gridHeaders == null || _dataTable == null) return; + + _gridViewDetails.ListViewColumnWidths = new int[gridHeaders.Count]; + var listViewColumnWidths = _gridViewDetails.ListViewColumnWidths; + + for (var i = 0; i < gridHeaders.Count; i++) + listViewColumnWidths[i] = gridHeaders[i].Length; + + foreach (var row in _dataTable.Data) + { + var index = 0; + 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++; + } + } + + _gridViewDetails.UsableWidth = Application.Top!.Frame.Width - MARGIN_LEFT - listViewColumnWidths.Length - + _gridViewDetails.ListViewOffset; + var columnWidthsSum = listViewColumnWidths.Sum(); + while (columnWidthsSum >= _gridViewDetails.UsableWidth) + { + var maxWidth = 0; + var maxIndex = 0; + for (var i = 0; i < listViewColumnWidths.Length; i++) + if (listViewColumnWidths[i] > maxWidth) + { + maxWidth = listViewColumnWidths[i]; + maxIndex = i; + } + + listViewColumnWidths[maxIndex]--; + columnWidthsSum--; + } + } + + #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 index cbdf4d6..4618dd4 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json @@ -14,7 +14,7 @@ "OCGV": { "commandName": "Executable", "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", - "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView }\"", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Select-Object ProcessName, Id, Handles, NPM, PM, WS, CPU | Out-ConsoleGridView}\"", "workingDirectory": "$(TargetDir)" }, "OCGV -MinUi": { 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 8f241a9..00da967 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs @@ -4,6 +4,7 @@ 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; @@ -142,6 +143,7 @@ protected override void EndProcessing() var applicationData = new ApplicationData { + PSObjects = _psObjects.Cast().ToList(), Title = Title ?? "Show-ObjectTree", Filter = Filter, MinUI = MinUI, @@ -150,7 +152,7 @@ protected override void EndProcessing() ModuleVersion = MyInvocation.MyCommand.Version.ToString() }; - ShowObjectView.Run(_psObjects, applicationData); + ShowObjectView.Run(applicationData); } /// 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 993fc91..df275fc 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs @@ -2,252 +2,49 @@ // 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 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; -internal sealed class ShowObjectView : Window, ITreeBuilder +/// +/// 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 { - private readonly TreeView? _tree; - private readonly View? _filterErrorView; - - public bool SupportsCanExpand => true; - private readonly Shortcut? _selectedShortcut; - private readonly StatusBar? _statusBar; - - public ShowObjectView(List rootObjects, 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(); - } - - 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) - }; - filterTextField.CursorPosition = filterTextField.Text.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() - }; - _tree.TreeBuilder = this; - _tree.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 += OnFilterTextFieldOnTextChanged; - - 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())); - - Shortcut 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)); - } - - _statusBar = new StatusBar(shortcuts); - _statusBar.Visible = !applicationData.MinUI; - - if (!applicationData.MinUI) - { - Add(filterLabel); - Add(filterTextField); - Add(_filterErrorView); - } - - Add(_tree); - Add(_statusBar); - return; - - void OnFilterTextFieldOnTextChanged(object? sender, EventArgs e) - { - 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; - } - } - - internal void SetRegexError(string error) - { - if (string.Equals(error, _filterErrorView?.Text, StringComparison.Ordinal)) return; - _filterErrorView?.Text = error; - } - - private void SelectionChanged(object? sender, SelectionChangedEventArgs e) - { - var selectedValue = e.NewValue; - - if (selectedValue is CachedMemberResult cmr) selectedValue = cmr.Value; - - if (selectedValue != null && _selectedShortcut != null) - _selectedShortcut.Title = selectedValue.GetType().Name; - else - _selectedShortcut?.Title = string.Empty; - - _statusBar?.SetNeedsDraw(); - } - - 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) => _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) => - value != null && value is not string && !value.GetType().IsValueType; - - 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; - } - } - - private static IEnumerable GetExtraChildren(object forObject) + /// + /// 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) { - if (forObject is DirectoryInfo dir) - foreach (var c in dir.EnumerateFileSystemInfos()) - yield return c; - } - - internal static void Run(List objects, ApplicationData applicationData) - { - // In Terminal.Gui v2, Application.Init() no longer accepts a driver parameter. - // Instead, use Application.ForceDriver to specify the driver. - if (applicationData.UseNetDriver) Application.ForceDriver = "NetDriver"; - Application.Init(); - Window? window = null; + if (applicationData.UseNetDriver) + Application.ForceDriver = "NetDriver"; + ShowObjectTreeWindow? window = null; try { - window = new ShowObjectView(objects.Select(p => p.BaseObject).ToList(), applicationData); + Application.Init(); + window = new ShowObjectTreeWindow(applicationData); Application.Run(window); } finally @@ -256,150 +53,16 @@ internal static void Run(List objects, ApplicationData applicationData Application.Shutdown(); } } -} - -internal sealed class CachedMemberResultElement -{ - public int Index; - public object? Value; - private readonly string _representation; - - public CachedMemberResultElement(object? value, int index) + /// + /// 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() { - Index = index; - Value = value; - - try - { - _representation = Value?.ToString() ?? "Null"; - } - catch (Exception) - { - Value = _representation = "Unavailable"; - } - } - - public override string ToString() => $"[{Index}]: {_representation}]"; -} - -internal sealed class CachedMemberResult -{ - public MemberInfo Member { get; set; } - public object? Value { get; set; } - public object Parent { get; set; } - private readonly 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 var elementType, out var 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 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; - } - - public override string ToString() => Member.Name + ": " + _representation; -} - -internal sealed class RegexTreeViewTextFilter(ShowObjectView parent, TreeView forTree) : ITreeViewFilter -{ - private readonly TreeView _forTree = forTree ?? throw new ArgumentNullException(nameof(forTree)); - - private string _text = string.Empty; - - public string Text - { - get => _text; - set - { - _text = value; - RefreshTreeView(); - } - } - - private void RefreshTreeView() - { - _forTree.InvalidateLineMap(); - _forTree.SetNeedsDraw(); - } - - 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; - } + // 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 2c2190d..4e9b16d 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Management.Automation; using System.Management.Automation.Internal; +using System.Management.Automation.Runspaces; using Microsoft.PowerShell.Commands; using Microsoft.PowerShell.OutGridView.Models; @@ -15,35 +16,65 @@ namespace Microsoft.PowerShell.ConsoleGuiTools; /// Provides methods to retrieve type information and convert PowerShell objects into data table structures for display /// in the grid view. /// -/// The PSCmdlet instance used to invoke PowerShell commands. -public class TypeGetter(PSCmdlet cmdlet) +public class TypeGetter { + private readonly Dictionary _formatCache = new(); + /// - /// Gets the format view definition for the specified PowerShell object by querying the format data. + /// Gets the format view definition for the specified type name, using a cache to avoid redundant lookups. /// - /// The PowerShell object to get the format view definition for. + /// The full type name to get the format view definition for. /// The format view definition if found; otherwise, . - public FormatViewDefinition? GetFormatViewDefinitionForObject(PSObject obj) + private FormatViewDefinition? GetFormatViewDefinitionForType(string typeName) { - var typeName = obj.BaseObject.GetType().FullName; + if (_formatCache.TryGetValue(typeName, out var cached)) return cached; - var types = cmdlet.InvokeCommand.InvokeScript(@"Microsoft.PowerShell.Utility\Get-FormatData " + typeName) - .ToList(); + // Always create a new runspace to avoid pipeline concurrency issues + // when called from within an active PowerShell cmdlet/pipeline + using var runspace = RunspaceFactory.CreateRunspace(); + runspace.Open(); - // No custom type definitions found - try the PowerShell specific format data - if (types.Count == 0) + try { - types = cmdlet.InvokeCommand - .InvokeScript( - @"Microsoft.PowerShell.Utility\Get-FormatData -PowerShellVersion $PSVersionTable.PSVersion " + - typeName).ToList(); + using var ps = System.Management.Automation.PowerShell.Create(); + ps.Runspace = runspace; + ps.AddCommand("Get-FormatData").AddParameter("TypeName", typeName); - if (types.Count == 0) return null; - } + var results = ps.Invoke(); - var extendedTypeDefinition = types[0].BaseObject as ExtendedTypeDefinition; + if (results.Count == 0) + { + ps.Commands.Clear(); + ps.AddCommand("Get-FormatData") + .AddParameter("TypeName", typeName); + results = ps.Invoke(); + } - return extendedTypeDefinition?.FormatViewDefinition[0]; + FormatViewDefinition? result = null; + if (results.Count > 0) + { + var extendedTypeDefinition = results[0].BaseObject as ExtendedTypeDefinition; + result = extendedTypeDefinition?.FormatViewDefinition[0]; + } + + _formatCache[typeName] = result; + return result; + } + finally + { + runspace.Close(); + } + } + + /// + /// Gets the format view definition for the specified PowerShell object using the current runspace. + /// + /// The PowerShell object to get the format view definition for. + /// The format view definition if found; otherwise, . + private FormatViewDefinition? GetFormatViewDefinitionForObject(PSObject obj) + { + var typeName = obj.BaseObject.GetType().FullName; + return GetFormatViewDefinitionForType(typeName!); } /// @@ -172,14 +203,13 @@ private List GetDataColumnsForObject(List psObjects) /// /// The list of PowerShell objects to convert. /// A containing the columns and rows extracted from the PowerShell objects. - public DataTable CastObjectsToTableView(List psObjects) + public static DataTable CastObjectsToTableView(List psObjects) { - var objectFormats = psObjects.Select(GetFormatViewDefinitionForObject).ToList(); - - var dataTableColumns = GetDataColumnsForObject(psObjects); + var typeGetter = new TypeGetter(); + var dataTableColumns = typeGetter.GetDataColumnsForObject(psObjects); var dataTableRows = new List(); - for (var i = 0; i < objectFormats.Count; i++) + for (var i = 0; i < psObjects.Count; i++) { var dataTableRow = CastObjectToDataTableRow(psObjects[i], dataTableColumns, i); dataTableRows.Add(dataTableRow); diff --git a/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs b/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs index 09b0987..f4509a9 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.Collections.Generic; +using System.Management.Automation; + namespace Microsoft.PowerShell.OutGridView.Models; /// @@ -9,9 +12,9 @@ namespace Microsoft.PowerShell.OutGridView.Models; public class ApplicationData { /// - /// Gets or sets the title displayed in the Out-GridView window. + /// Gets or sets the PowerShell objects to display. /// - public string? Title { get; set; } + public List? PSObjects { get; set; } /// /// Gets or sets the output mode that determines how items can be selected and returned. @@ -19,19 +22,20 @@ public class ApplicationData public OutputModeOption OutputMode { get; set; } /// - /// Gets or sets the filter text to apply to the data table. + /// Gets or sets the title displayed in the Out-GridView window. /// - public string? Filter { get; set; } + public string? Title { get; set; } + /// - /// Gets or sets a value indicating whether to use minimal UI mode. + /// Gets or sets the filter text to apply to the data. /// - public bool MinUI { get; set; } + public string? Filter { get; set; } /// - /// Gets or sets the data table containing the columns and rows to display. + /// Gets or sets a value indicating whether to use minimal UI mode. /// - public DataTable? DataTable { get; set; } + public bool MinUI { get; set; } /// /// Gets or sets a value indicating whether to use the .NET driver for rendering. @@ -52,4 +56,5 @@ public class ApplicationData /// 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/Microsoft.PowerShell.OutGridView.Models.csproj b/src/Microsoft.PowerShell.OutGridView.Models/Microsoft.PowerShell.OutGridView.Models.csproj index 949d2b5..71d447e 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/Microsoft.PowerShell.OutGridView.Models.csproj +++ b/src/Microsoft.PowerShell.OutGridView.Models/Microsoft.PowerShell.OutGridView.Models.csproj @@ -7,5 +7,6 @@ + From b0cdb959c265db37ec0d166569588772e7c17646 Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 14 Nov 2025 12:26:34 -0700 Subject: [PATCH 12/19] Enhance null checks, caching, and code readability Added a null check in `GetFormatViewDefinitionForObject` to handle cases where `typeName` is null, returning early if necessary. Introduced a caching mechanism using `_formatCache.TryGetValue` to improve performance by reusing cached format view definitions. Refactored object-to-data-table conversion code for better readability by reformatting inline initializations of `DecimalValue` and `StringValue`. Improved clarity in data type assignment logic by wrapping nested loops in braces and adopting modern C# syntax (`is not`) for type checks. --- .../TypeGetter.cs | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs index 4e9b16d..2ff1621 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs @@ -73,8 +73,14 @@ public class TypeGetter /// The format view definition if found; otherwise, . private FormatViewDefinition? GetFormatViewDefinitionForObject(PSObject obj) { - var typeName = obj.BaseObject.GetType().FullName; - return GetFormatViewDefinitionForType(typeName!); + string? typeName = obj.BaseObject.GetType().FullName; + if (typeName is null) + { + return null; + } + if (_formatCache.TryGetValue(typeName, out var cached)) + return cached; + return GetFormatViewDefinitionForType(typeName); } /// @@ -102,13 +108,13 @@ public static DataTableRow CastObjectToDataTableRow(PSObject ps, List dataTableRows, List // 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) - if (!(dataRow[dataColumn.ToString()] is DecimalValue)) - dataColumn.StringType = typeof(string).FullName; + { + foreach (var dataColumn in dataTableColumns) + { + if (dataRow[dataColumn.ToString()] is not DecimalValue) + dataColumn.StringType = typeof(string).FullName; + } + } } /// From b8dbaa6b7d1685af70cd7bb89c22f5a255ff30e5 Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 14 Nov 2025 12:28:22 -0700 Subject: [PATCH 13/19] Refactor runspace creation in TypeGetter.cs Updated `GetFormatViewDefinitionForType` to create a runspace with the default initial session state, ensuring proper loading of format data. Removed redundant fallback logic for `Get-FormatData` invocation. Added `Debug.Assert` to validate non-null results. Updated comments to reflect the new behavior. Included `System.Diagnostics` for debugging utilities. --- .../TypeGetter.cs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs index 2ff1621..2c3f0d1 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.Linq; using System.Management.Automation; @@ -29,9 +30,9 @@ public class TypeGetter { if (_formatCache.TryGetValue(typeName, out var cached)) return cached; - // Always create a new runspace to avoid pipeline concurrency issues - // when called from within an active PowerShell cmdlet/pipeline - using var runspace = RunspaceFactory.CreateRunspace(); + // Create a runspace with the default initial session state to load format data + var iss = InitialSessionState.CreateDefault(); + using var runspace = RunspaceFactory.CreateRunspace(iss); runspace.Open(); try @@ -42,14 +43,6 @@ public class TypeGetter var results = ps.Invoke(); - if (results.Count == 0) - { - ps.Commands.Clear(); - ps.AddCommand("Get-FormatData") - .AddParameter("TypeName", typeName); - results = ps.Invoke(); - } - FormatViewDefinition? result = null; if (results.Count > 0) { @@ -57,6 +50,7 @@ public class TypeGetter result = extendedTypeDefinition?.FormatViewDefinition[0]; } + Debug.Assert(result is not null); _formatCache[typeName] = result; return result; } From c5f76739341a263215a5ecccbcecf70c1de3d79c Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 14 Nov 2025 15:03:56 -0700 Subject: [PATCH 14/19] Refactor and enhance object formatting in OCGV Enhanced object formatting and display logic in the `Out-ConsoleGridView` cmdlet: - Added `GetFormatDataForObjects` to retrieve format data for PowerShell objects. - Updated `EndProcessing` to include format data in `ApplicationData`. - Improved `DataTable` construction to use format data and added dynamic column formatting. - Introduced `IsIdentifierProperty` to handle identifier-specific formatting. - Added `FormatString` and `FormatValue` to `DataTableColumn` for flexible value formatting. - Enhanced `IValue` interface with `OriginalValue` for raw data access. Refactored `TypeGetter` to simplify format data retrieval and object-to-table conversion. Updated `launchSettings.json` to include new profiles and simplify commands. Performed general code cleanup, removed legacy methods, and improved documentation for better maintainability. --- .../OutConsoleGridviewCmdletCommand.cs | 37 +++ .../OutGridViewWindow.cs | 93 ++++++- .../Properties/launchSettings.json | 8 +- .../TypeGetter.cs | 245 +++++++----------- .../ApplicationData.cs | 6 +- .../DataTableColumn.cs | 49 +++- .../DataTableRow.cs | 20 ++ 7 files changed, 295 insertions(+), 163 deletions(-) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs index b330d28..4a4d1bd 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs @@ -157,9 +157,12 @@ protected override void EndProcessing() // Return if no objects if (_psObjects.Count == 0) return; + var formatData = GetFormatDataForObjects(_psObjects); + var applicationData = new ApplicationData { PSObjects = _psObjects.Cast().ToList(), + FormatData = formatData.ToList(), Title = Title ?? "Out-ConsoleGridView", OutputMode = OutputMode, Filter = Filter, @@ -187,4 +190,38 @@ public void Dispose() _outConsoleGridView.Dispose(); GC.SuppressFinalize(this); } + + /// + /// Gets the format data (property information) for the PowerShell objects using PowerShell's formatting system. + /// + /// The list of PowerShell objects to get format data for. + /// A collection of PSPropertyInfo representing the properties to display. + private IEnumerable GetFormatDataForObjects(List psObjects) + { + if (psObjects.Count == 0) + { + return Array.Empty(); + } + + var firstObject = psObjects[0]; + + // Try to get the DefaultDisplayPropertySet from PSStandardMembers + var standardMembers = firstObject.Members["PSStandardMembers"]?.Value as PSMemberSet; + var defaultDisplayPropertySet = standardMembers?.Members["DefaultDisplayPropertySet"]?.Value as PSPropertySet; + + if (defaultDisplayPropertySet?.ReferencedPropertyNames != null && + defaultDisplayPropertySet.ReferencedPropertyNames.Count > 0) + { + // Return only the properties in the DefaultDisplayPropertySet + return defaultDisplayPropertySet.ReferencedPropertyNames + .Select(name => firstObject.Properties[name]) + .Where(prop => prop != null) + .Cast(); + } + + // Fall back to all visible properties (excluding PS* internal properties) + return firstObject.Properties + .Where(p => !p.Name.StartsWith("PS", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } } \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs index 4e74ddf..3d692e6 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs @@ -54,12 +54,53 @@ public OutGridViewWindow(ApplicationData applicationData) : MARGIN_LEFT }; - // Convert PSObjects to DataTable - if (_applicationData.PSObjects is { Count: > 0 }) + // Convert PSObjects to DataTable using the provided format data + if (_applicationData.PSObjects is { Count: > 0 } && _applicationData.FormatData is { Count: > 0 }) { - var typeGetter = new TypeGetter(); var psObjects = _applicationData.PSObjects.Cast().ToList(); - _dataTable = TypeGetter.CastObjectsToTableView(psObjects); + + // Create columns from the format data with format strings based on property type AND name + var dataTableColumns = _applicationData.FormatData + .Select(prop => + { + var column = new DataTableColumn(prop.Name, $"$_.{prop.Name}"); + + // Set format string based on property type and name + var propType = prop.TypeNameOfValue; + var propName = prop.Name; + + column.FormatString = propType switch + { + "System.DateTime" => "G", // General date/time + "System.Decimal" => "N2", // Decimal with 2 decimal places + "System.Double" or "System.Single" => "N2", // Floating point with 2 decimals + + // For integers, check if it's an identifier or a quantity + "System.Int32" or "System.Int64" or "System.Int16" or "System.Byte" + when IsIdentifierProperty(propName) => null, // No formatting for IDs + + "System.Int32" or "System.Int64" or "System.Int16" or "System.Byte" + => "N0", // Quantities get thousand separators + + _ => null + }; + + return column; + }) + .ToList(); + + // Convert each object to a row + var dataTableRows = new List(); + for (var i = 0; i < psObjects.Count; i++) + { + var dataTableRow = TypeGetter.CastObjectToDataTableRow(psObjects[i], _applicationData.FormatData, dataTableColumns, i); + dataTableRows.Add(dataTableRow); + } + + // Set the column types based on the actual data + SetTypesOnDataColumns(dataTableRows, dataTableColumns); + + _dataTable = new DataTable(dataTableColumns, dataTableRows); } else { @@ -81,6 +122,50 @@ public OutGridViewWindow(ApplicationData applicationData) _listView?.SetFocus(); } + /// + /// Determines if a property name represents an identifier rather than a quantity. + /// Identifiers should not have thousand separators. + /// + /// The name of the property. + /// True if the property is likely an identifier; otherwise false. + private static bool IsIdentifierProperty(string propertyName) + { + // Common identifier property names + return propertyName.Equals("Id", StringComparison.OrdinalIgnoreCase) || + propertyName.EndsWith("Id", StringComparison.OrdinalIgnoreCase) || + propertyName.Equals("PID", StringComparison.OrdinalIgnoreCase) || + propertyName.Equals("ProcessId", StringComparison.OrdinalIgnoreCase) || + propertyName.Equals("SessionId", StringComparison.OrdinalIgnoreCase) || + propertyName.Equals("SI", StringComparison.OrdinalIgnoreCase) || + propertyName.Equals("ParentProcessId", StringComparison.OrdinalIgnoreCase) || + propertyName.Equals("ThreadId", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Sets the data type on each column based on the values in the data rows. + /// If all values in a column can be parsed as decimal, the column type is set to decimal; otherwise, it's set to + /// string. + /// + /// The list of data table rows to analyze. + /// The list of data table columns to update with type information. + 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; + + // 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) + { + if (dataRow[dataColumn.ToString()] is not DecimalValue) + dataColumn.StringType = typeof(string).FullName; + } + } + } + /// /// Gets a value indicating whether the user cancelled the operation. /// diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json index 4618dd4..21108bf 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json @@ -14,7 +14,7 @@ "OCGV": { "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}\"", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView}\"", "workingDirectory": "$(TargetDir)" }, "OCGV -MinUi": { @@ -28,6 +28,12 @@ "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)" } } } \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs index 2c3f0d1..790a731 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs @@ -1,114 +1,106 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections.Generic; -using System.Diagnostics; using System.Globalization; using System.Linq; using System.Management.Automation; -using System.Management.Automation.Internal; -using System.Management.Automation.Runspaces; -using Microsoft.PowerShell.Commands; using Microsoft.PowerShell.OutGridView.Models; namespace Microsoft.PowerShell.ConsoleGuiTools; /// /// Provides methods to retrieve type information and convert PowerShell objects into data table structures for display -/// in the grid view. +/// in the grid view using PowerShell's native formatting infrastructure. /// public class TypeGetter { - private readonly Dictionary _formatCache = new(); - /// - /// Gets the format view definition for the specified type name, using a cache to avoid redundant lookups. + /// Gets the properties to display for the PowerShell objects using PowerShell's formatting system. /// - /// The full type name to get the format view definition for. - /// The format view definition if found; otherwise, . - private FormatViewDefinition? GetFormatViewDefinitionForType(string typeName) + /// The list of PowerShell objects to get format data for. + /// A list of property information representing the properties to display. + public static List GetFormatDataForObjects(List psObjects) { - if (_formatCache.TryGetValue(typeName, out var cached)) return cached; - - // Create a runspace with the default initial session state to load format data - var iss = InitialSessionState.CreateDefault(); - using var runspace = RunspaceFactory.CreateRunspace(iss); - runspace.Open(); - - try + if (psObjects.Count == 0) { - using var ps = System.Management.Automation.PowerShell.Create(); - ps.Runspace = runspace; - ps.AddCommand("Get-FormatData").AddParameter("TypeName", typeName); + return []; + } - var results = ps.Invoke(); + var firstObject = psObjects[0]; - FormatViewDefinition? result = null; - if (results.Count > 0) - { - var extendedTypeDefinition = results[0].BaseObject as ExtendedTypeDefinition; - result = extendedTypeDefinition?.FormatViewDefinition[0]; - } + // Try to get the DefaultDisplayPropertySet from PSStandardMembers + var standardMembers = firstObject.Members["PSStandardMembers"]?.Value as PSMemberSet; + var defaultDisplayPropertySet = standardMembers?.Members["DefaultDisplayPropertySet"]?.Value as PSPropertySet; - Debug.Assert(result is not null); - _formatCache[typeName] = result; - return result; - } - finally + if (defaultDisplayPropertySet?.ReferencedPropertyNames != null && + defaultDisplayPropertySet.ReferencedPropertyNames.Count > 0) { - runspace.Close(); + // Return only the properties in the DefaultDisplayPropertySet + return defaultDisplayPropertySet.ReferencedPropertyNames + .Select(name => firstObject.Properties[name]) + .Where(prop => prop != null) + .Cast() + .ToList(); } - } - /// - /// Gets the format view definition for the specified PowerShell object using the current runspace. - /// - /// The PowerShell object to get the format view definition for. - /// The format view definition if found; otherwise, . - private FormatViewDefinition? GetFormatViewDefinitionForObject(PSObject obj) - { - string? typeName = obj.BaseObject.GetType().FullName; - if (typeName is null) - { - return null; - } - if (_formatCache.TryGetValue(typeName, out var cached)) - return cached; - return GetFormatViewDefinitionForType(typeName); + // Fall back to all visible properties (excluding PS* internal properties) + return firstObject.Properties + .Where(p => !p.Name.StartsWith("PS", StringComparison.OrdinalIgnoreCase)) + .ToList(); } /// - /// Converts a PowerShell object to a data table row with values extracted based on the specified columns. + /// Converts a PowerShell object to a data table row with values extracted based on the specified properties and columns. /// - /// The PowerShell object to convert. - /// The list of columns defining which properties to extract. + /// The PowerShell object to convert. + /// The list of properties to extract. + /// The list of columns with format specifications. /// The original index of the object in the source collection. /// A containing the extracted values. - public static DataTableRow CastObjectToDataTableRow(PSObject ps, List dataColumns, int objectIndex) + public static DataTableRow CastObjectToDataTableRow(PSObject psObject, List properties, List dataTableColumns, int objectIndex) { var valuePairs = new Dictionary(); - foreach (var dataColumn in dataColumns) + for (int i = 0; i < properties.Count && i < dataTableColumns.Count; i++) { - var expression = new PSPropertyExpression(ScriptBlock.Create(dataColumn.PropertyScriptAccessor)); + var property = properties[i]; + var column = dataTableColumns[i]; + var propValue = psObject.Properties[property.Name]; + object? rawValue = null; - var result = expression.GetValues(ps).FirstOrDefault()?.Result; + try + { + rawValue = propValue?.Value; + } + catch + { + // If property access fails, use null + } - var stringValue = result?.ToString() ?? string.Empty; + // Use the column's FormatValue method to create the display string + var displayValue = column.FormatValue(rawValue); - var isDecimal = decimal.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat, - out var decimalValue); + // Determine if this is a numeric value for sorting + var isDecimal = rawValue is decimal or int or long or short or byte or double or float; if (isDecimal) { - valuePairs[dataColumn.ToString()] = new DecimalValue - { DisplayValue = stringValue, SortValue = decimalValue }; + var decimalValue = Convert.ToDecimal(rawValue, CultureInfo.InvariantCulture); + valuePairs[column.ToString()] = new DecimalValue + { + DisplayValue = displayValue, + SortValue = decimalValue + }; } else { - var stringDecorated = new StringDecorated(stringValue); - valuePairs[dataColumn.ToString()] = new StringValue - { DisplayValue = stringDecorated.ToString(OutputRendering.PlainText) }; + valuePairs[column.ToString()] = new StringValue + { + DisplayValue = displayValue, + RawValue = rawValue + }; } } @@ -126,7 +118,8 @@ private static void SetTypesOnDataColumns(List dataTableRows, List { var dataRows = dataTableRows.Select(x => x.Values); - foreach (var dataColumn in dataTableColumns) dataColumn.StringType = typeof(decimal).FullName; + foreach (var dataColumn in dataTableColumns) + dataColumn.StringType = typeof(decimal).FullName; // If every value in a column could be a decimal, assume that it is supposed to be a decimal foreach (var dataRow in dataRows) @@ -140,82 +133,46 @@ private static void SetTypesOnDataColumns(List dataTableRows, List } /// - /// Retrieves the column definitions for the specified PowerShell objects based on their format view definitions or - /// properties. + /// Converts a list of PowerShell objects into a data table structure suitable for display in the grid view. /// - /// The list of PowerShell objects to analyze. - /// A distinct list of data table columns. - private List GetDataColumnsForObject(List psObjects) + /// The list of PowerShell objects to convert. + /// A containing the columns and rows extracted from the PowerShell objects. + public static DataTable CastObjectsToTableView(List psObjects) { - var dataColumns = new List(); - - foreach (var obj in psObjects) + if (psObjects.Count == 0) { - List labels; - - var fvd = GetFormatViewDefinitionForObject(obj); + return new DataTable([], []); + } - List propertyAccessors; + // Get the properties to display using PowerShell's format data + var properties = GetFormatDataForObjects(psObjects); - if (fvd == null) + // Create columns from the properties with format strings based on property type + var dataTableColumns = properties + .Select(prop => { - if (PSObjectIsPrimitive(obj)) - { - labels = [obj.BaseObject.GetType().Name]; - propertyAccessors = ["$_"]; - } - else - { - labels = obj.Properties.Select(x => x.Name).ToList(); - propertyAccessors = obj.Properties.Select(x => $"$_.\"{x.Name}\"").ToList(); - } - } - else - { - var tableControl = fvd.Control as TableControl; - - var definedColumnLabels = tableControl?.Headers.Select(x => x.Label); - - var displayEntries = tableControl?.Rows[0].Columns.Select(x => x.DisplayEntry); - - var enumerable = displayEntries as DisplayEntry[] ?? displayEntries!.ToArray(); - var propertyLabels = enumerable.Select(x => x.Value); + var column = new DataTableColumn(prop.Name, $"$_.{prop.Name}"); - // Use the TypeDefinition Label if available otherwise just use the property name as a label - labels = (definedColumnLabels ?? []).Zip(propertyLabels, (definedColumnLabel, propertyLabel) => + // Set format string based on property type + var propType = prop.TypeNameOfValue; + column.FormatString = propType switch { - if (string.IsNullOrEmpty(definedColumnLabel)) return propertyLabel; - - return definedColumnLabel; - }).ToList(); - - propertyAccessors = enumerable.Select(x => x.ValueType == DisplayEntryValueType.Property - ? $"$_.\"{x.Value}\"" - : - // Otherwise return access script - x.Value).ToList(); - } - - dataColumns.AddRange(labels.Select((t, i) => new DataTableColumn(t, propertyAccessors[i]))); - } - - return dataColumns.Distinct().ToList(); - } - - /// - /// 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. - /// A containing the columns and rows extracted from the PowerShell objects. - public static DataTable CastObjectsToTableView(List psObjects) - { - var typeGetter = new TypeGetter(); - var dataTableColumns = typeGetter.GetDataColumnsForObject(psObjects); - + "System.DateTime" => "G", // General date/time + "System.Decimal" => "N2", // Decimal with 2 decimal places + "System.Int32" or "System.Int64" or "System.Int16" or "System.Byte" => "N0", // Integer numbers with thousand separators + "System.Double" or "System.Single" => "N2", // Floating point with 2 decimals + _ => null + }; + + return column; + }) + .ToList(); + + // Convert each object to a row var dataTableRows = new List(); for (var i = 0; i < psObjects.Count; i++) { - var dataTableRow = CastObjectToDataTableRow(psObjects[i], dataTableColumns, i); + var dataTableRow = CastObjectToDataTableRow(psObjects[i], properties, dataTableColumns, i); dataTableRows.Add(dataTableRow); } @@ -223,28 +180,4 @@ public static DataTable CastObjectsToTableView(List psObjects) return new DataTable(dataTableColumns, dataTableRows); } - - /// - /// 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 in the PowerShell context. - /// - /// The PowerShell object to check. - /// if the object is a primitive type; otherwise, . - private static bool PSObjectIsPrimitive(PSObject ps) - { - var psBaseType = ps.BaseObject.GetType(); - - return psBaseType.IsPrimitive || psBaseType.IsEnum || ADDITIONAL_PRIMITIVE_TYPES.Contains(psBaseType.FullName!); - } } \ 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 f4509a9..eb86293 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs @@ -16,6 +16,11 @@ public class ApplicationData /// public List? PSObjects { get; set; } + /// + /// Gets or sets the PowerShell format data for the objects in . + /// + public List? FormatData { get; set; } + /// /// Gets or sets the output mode that determines how items can be selected and returned. /// @@ -56,5 +61,4 @@ public class ApplicationData /// 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/DataTableColumn.cs b/src/Microsoft.PowerShell.OutGridView.Models/DataTableColumn.cs index 1f432e3..a2e70a3 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/DataTableColumn.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/DataTableColumn.cs @@ -20,7 +20,7 @@ public class DataTableColumn(string label, string propertyScriptAccessor) /// Gets the runtime type of the column based on the property. /// [JsonIgnore] - public Type? Type => Type.GetType(StringType!); + public Type? Type => System.Type.GetType(StringType!); /// /// Gets the display label for the column. @@ -32,11 +32,58 @@ public class DataTableColumn(string label, string propertyScriptAccessor) /// 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) + { + if (value == null) return string.Empty; + + // If we have a format string, try to use it + if (!string.IsNullOrEmpty(FormatString) && value is IFormattable formattable) + { + try + { + return formattable.ToString(FormatString, System.Globalization.CultureInfo.CurrentCulture); + } + catch + { + // Fall through to default formatting if format string is invalid + } + } + + // If FormatString is explicitly null, use simple ToString for most types + // (this prevents unwanted formatting of identifier integers like ProcessId) + if (FormatString == null) + { + return value.ToString() ?? string.Empty; + } + + // Default formatting based on type (only when FormatString is empty but not null) + return value switch + { + 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. diff --git a/src/Microsoft.PowerShell.OutGridView.Models/DataTableRow.cs b/src/Microsoft.PowerShell.OutGridView.Models/DataTableRow.cs index 3e27a04..5eb958d 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/DataTableRow.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/DataTableRow.cs @@ -15,6 +15,11 @@ public interface IValue : IComparable /// 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; } } /// @@ -32,6 +37,11 @@ public class DecimalValue : IValue /// 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. /// @@ -57,6 +67,16 @@ public class StringValue : IValue /// 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. /// From f8eec6287ea6d0b9da1dc985e6012b65d2c808ac Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 14 Nov 2025 17:57:56 -0700 Subject: [PATCH 15/19] Refactor and enhance grid view rendering logic Refactored `Render` method in `GridViewDataSource` to improve alignment handling. Enhanced column width management in `OutGridViewWindow` by introducing natural width calculations, truncation safeguards, and scrollbar support. Simplified layout handling with dynamic content size adjustments. Removed deprecated `GetFormatDataForObjects` method and `FormatData` property, transitioning to dynamic column definitions using `TypeGetter`. Improved type handling with caching, ANSI escape sequence stripping, and better fallback logic for primitive types. Updated launch configurations for testing new scenarios. Performed general code cleanup, improving readability, maintainability, and error handling. --- .../GridViewDataSource.cs | 2 +- .../GridViewHelpers.cs | 22 +- .../OutConsoleGridviewCmdletCommand.cs | 37 --- .../OutGridViewWindow.cs | 204 +++++++------ .../Properties/launchSettings.json | 8 +- .../TypeGetter.cs | 276 +++++++++++++----- .../ApplicationData.cs | 6 - 7 files changed, 338 insertions(+), 217 deletions(-) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs index a85350f..3bbe962 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs @@ -63,7 +63,7 @@ public GridViewDataSource(List itemList) /// 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, line); + listView.Move(col - start, line); var driver = Application.Driver; var row = GridViewRowList[item]; diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs index cdae77d..44ee449 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -34,14 +35,6 @@ public static List FilterData(List listToFilter, strin return filteredList; } - /// - /// Formats a list of strings into a single padded string for display in the grid view. - /// Each string is padded to fit its corresponding column width, with newlines replaced by encoded representations. - /// - /// The list of strings to format, one per column. - /// The left offset (padding) to add before the first column. - /// The width of each column. If null, an empty string is returned. - /// A formatted string with columns padded and separated by spaces, or an empty string if column widths are not provided. public static string GetPaddedString(List? strings, int offset, int[]? listViewColumnWidths) { if (listViewColumnWidths is null) @@ -71,7 +64,18 @@ public static string GetPaddedString(List? strings, int offset, int[]? l strings[i] = strings[i].Replace("\n", "`n"); // If the string doesn't fit in the column, append an ellipsis. - if (strings[i].Length > listViewColumnWidths[i]) + // 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("..."); diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs index 4a4d1bd..b330d28 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs @@ -157,12 +157,9 @@ protected override void EndProcessing() // Return if no objects if (_psObjects.Count == 0) return; - var formatData = GetFormatDataForObjects(_psObjects); - var applicationData = new ApplicationData { PSObjects = _psObjects.Cast().ToList(), - FormatData = formatData.ToList(), Title = Title ?? "Out-ConsoleGridView", OutputMode = OutputMode, Filter = Filter, @@ -190,38 +187,4 @@ public void Dispose() _outConsoleGridView.Dispose(); GC.SuppressFinalize(this); } - - /// - /// Gets the format data (property information) for the PowerShell objects using PowerShell's formatting system. - /// - /// The list of PowerShell objects to get format data for. - /// A collection of PSPropertyInfo representing the properties to display. - private IEnumerable GetFormatDataForObjects(List psObjects) - { - if (psObjects.Count == 0) - { - return Array.Empty(); - } - - var firstObject = psObjects[0]; - - // Try to get the DefaultDisplayPropertySet from PSStandardMembers - var standardMembers = firstObject.Members["PSStandardMembers"]?.Value as PSMemberSet; - var defaultDisplayPropertySet = standardMembers?.Members["DefaultDisplayPropertySet"]?.Value as PSPropertySet; - - if (defaultDisplayPropertySet?.ReferencedPropertyNames != null && - defaultDisplayPropertySet.ReferencedPropertyNames.Count > 0) - { - // Return only the properties in the DefaultDisplayPropertySet - return defaultDisplayPropertySet.ReferencedPropertyNames - .Select(name => firstObject.Properties[name]) - .Where(prop => prop != null) - .Cast(); - } - - // Fall back to all visible properties (excluding PS* internal properties) - return firstObject.Properties - .Where(p => !p.Name.StartsWith("PS", StringComparison.OrdinalIgnoreCase)) - .ToList(); - } } \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs index 3d692e6..10e9eab 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Drawing; +using System.Globalization; using System.Linq; using System.Management.Automation; using System.Reflection; @@ -27,13 +29,14 @@ internal sealed class OutGridViewWindow : Window private Label? _filterLabel; private TextField? _filterField; private View? _filterErrorView; - private Label? _header; + private View? _header; private ListView? _listView; private GridViewDataSource? _inputSource; private GridViewDataSource? _listViewSource; private readonly ApplicationData _applicationData; private readonly GridViewDetails _gridViewDetails; private readonly DataTable _dataTable; + private int[]? _naturalColumnWidths; /// /// Initializes a new instance of the class with the specified application data. @@ -54,62 +57,17 @@ public OutGridViewWindow(ApplicationData applicationData) : MARGIN_LEFT }; - // Convert PSObjects to DataTable using the provided format data - if (_applicationData.PSObjects is { Count: > 0 } && _applicationData.FormatData is { Count: > 0 }) + // Convert PSObjects to DataTable using TypeGetter which handles format data properly + if (_applicationData.PSObjects is { Count: > 0 }) { var psObjects = _applicationData.PSObjects.Cast().ToList(); - - // Create columns from the format data with format strings based on property type AND name - var dataTableColumns = _applicationData.FormatData - .Select(prop => - { - var column = new DataTableColumn(prop.Name, $"$_.{prop.Name}"); - - // Set format string based on property type and name - var propType = prop.TypeNameOfValue; - var propName = prop.Name; - - column.FormatString = propType switch - { - "System.DateTime" => "G", // General date/time - "System.Decimal" => "N2", // Decimal with 2 decimal places - "System.Double" or "System.Single" => "N2", // Floating point with 2 decimals - - // For integers, check if it's an identifier or a quantity - "System.Int32" or "System.Int64" or "System.Int16" or "System.Byte" - when IsIdentifierProperty(propName) => null, // No formatting for IDs - - "System.Int32" or "System.Int64" or "System.Int16" or "System.Byte" - => "N0", // Quantities get thousand separators - - _ => null - }; - - return column; - }) - .ToList(); - - // Convert each object to a row - var dataTableRows = new List(); - for (var i = 0; i < psObjects.Count; i++) - { - var dataTableRow = TypeGetter.CastObjectToDataTableRow(psObjects[i], _applicationData.FormatData, dataTableColumns, i); - dataTableRows.Add(dataTableRow); - } - - // Set the column types based on the actual data - SetTypesOnDataColumns(dataTableRows, dataTableColumns); - - _dataTable = new DataTable(dataTableColumns, dataTableRows); + _dataTable = TypeGetter.CastObjectsToTableView(psObjects); } else { _dataTable = new DataTable([], []); } - // Copy the input DataTable into our master ListView source list - _inputSource = LoadData(); - if (!_applicationData.MinUI) { AddFilter(); @@ -120,6 +78,17 @@ public OutGridViewWindow(ApplicationData applicationData) 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(); + + if (_header is { }) + _header.Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails.ListViewOffset, + _gridViewDetails.ListViewColumnWidths); + } /// @@ -196,17 +165,33 @@ public HashSet GetSelectedIndexes() private GridViewDataSource LoadData() { var items = new List(); - if (_dataTable == null || _dataTable.Data.Count == 0) + if (_dataTable.Data.Count == 0) return new GridViewDataSource(items); + // Calculate and cache natural column widths + var gridHeaders = _dataTable.DataColumns.Select(c => c.Label).ToList(); + _naturalColumnWidths = CalculateNaturalColumnWidths(gridHeaders); + _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 dataValue = dataTableRow.Values[dataTableColumn.ToString()].DisplayValue; - valueList.Add(dataValue); + var columnKey = dataTableColumn.ToString(); + + // Check if the key exists in the dictionary + if (dataTableRow.Values.TryGetValue(columnKey, out var value)) + { + valueList.Add(value.DisplayValue); + } + else + { + // 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 + valueList.Add(string.Empty); + } } var displayString = GridViewHelpers.GetPaddedString(valueList, 0, _gridViewDetails.ListViewColumnWidths); @@ -227,7 +212,7 @@ private GridViewDataSource LoadData() /// The data source containing rows to update. private void UpdateDisplayStrings(GridViewDataSource? source) { - if (source == null || _dataTable == null) return; + if (source == null) return; foreach (var gvr in source.GridViewRowList) { @@ -388,9 +373,11 @@ private void AddFilter() /// private void AddHeaders() { - _header = new Label + _header = new View { - Y = _applicationData.MinUI ? 0 : Pos.Bottom(_filterErrorView!) + Y = _applicationData.MinUI ? 0 : Pos.Bottom(_filterErrorView!), + Height = 1, + Width = Dim.Auto(DimAutoStyle.Text) }; Add(_header); @@ -416,11 +403,13 @@ private void AddListView() Source = _inputSource, X = MARGIN_LEFT, Y = !_applicationData.MinUI ? Pos.Bottom(_filterLabel!) + 2 : 1, - Width = Dim.Fill(1), - Height = Dim.Fill(), + Width = Dim.Fill(), + Height = Dim.Fill(1), AllowsMarking = _applicationData.OutputMode != OutputModeOption.None, AllowsMultipleSelection = _applicationData.OutputMode == OutputModeOption.Multiple, - SelectedItem = 0 + SelectedItem = 0, + VerticalScrollBar = { AutoShow = true }, + HorizontalScrollBar = { AutoShow = true } }; _listView.KeyBindings.Remove(Key.A.WithCtrl); @@ -497,61 +486,98 @@ private void AddStatusBar() protected override void OnSubViewLayout(LayoutEventArgs args) { // Create the headers and calculate column widths based on the DataTable - var gridHeaders = _dataTable?.DataColumns.Select(c => c.Label).ToList(); - CalculateColumnWidths(gridHeaders); - if (_header is { }) - _header.Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails.ListViewOffset, - _gridViewDetails.ListViewColumnWidths); - UpdateDisplayStrings(_listViewSource); - ApplyFilter(); + //if (_naturalColumnWidths!.Sum() > Viewport.Width - 1) + //{ + // _listView!.HorizontalScrollBar.Visible = true; + //} + //else + //{ + // _listView!.HorizontalScrollBar.Visible = false; + //} + //UpdateDisplayStrings(_listViewSource); + + //ApplyFilter(); base.OnSubViewLayout(args); + + } + + protected override void OnSubViewsLaidOut(LayoutEventArgs args) + { + base.OnSubViewsLaidOut(args); + _listView?.SetContentSize(new Size(_naturalColumnWidths!.Sum(), _listView.GetContentSize().Height)); } /// - /// Calculates optimal column widths based on header and data content, fitting within the available screen width. + /// Calculates the natural column widths needed to display all data without truncation. /// /// The column headers for the grid. - private void CalculateColumnWidths(List? gridHeaders) + /// An array of column widths where each width is the maximum needed for that column. + private int[] CalculateNaturalColumnWidths(List? gridHeaders) { - if (gridHeaders == null || _dataTable == null) return; + if (gridHeaders == null) + return []; - _gridViewDetails.ListViewColumnWidths = new int[gridHeaders.Count]; - var listViewColumnWidths = _gridViewDetails.ListViewColumnWidths; + var columnWidths = new int[gridHeaders.Count]; + // Start with header widths for (var i = 0; i < gridHeaders.Count; i++) - listViewColumnWidths[i] = gridHeaders[i].Length; + columnWidths[i] = gridHeaders[i].Length; + // Expand to fit data foreach (var row in _dataTable.Data) { - var index = 0; - foreach (var col in row.Values.Take(Application.Top!.Frame.Height / 2)) + for (var i = 0; i < _dataTable.DataColumns.Count; i++) { - var len = col.Value.DisplayValue.Length; - if (len > listViewColumnWidths[index]) - listViewColumnWidths[index] = len; - index++; + 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; + } } } - _gridViewDetails.UsableWidth = Application.Top!.Frame.Width - MARGIN_LEFT - listViewColumnWidths.Length - - _gridViewDetails.ListViewOffset; - var columnWidthsSum = listViewColumnWidths.Sum(); - while (columnWidthsSum >= _gridViewDetails.UsableWidth) + return columnWidths; + } + + /// + /// Calculates optimal column widths based on header and data content, fitting within the available screen width. + /// + /// The column headers for the grid. + /// + /// If the column widths could not be calculated. + private int[]? CalculateColumnWidths(List? gridHeaders, int width) + { + if (gridHeaders == null) return null; + + var listViewColumnWidths = _naturalColumnWidths; + + while (GetCurrentTotal(listViewColumnWidths) > width) { - var maxWidth = 0; - var maxIndex = 0; - for (var i = 0; i < listViewColumnWidths.Length; i++) - if (listViewColumnWidths[i] > maxWidth) + // Find the rightmost column with width > 0 and shrink it + var shrinkIndex = -1; + for (var i = listViewColumnWidths.Length - 1; i >= 0; i--) + { + if (listViewColumnWidths[i] > 0) { - maxWidth = listViewColumnWidths[i]; - maxIndex = i; + shrinkIndex = i; + break; } + } - listViewColumnWidths[maxIndex]--; - columnWidthsSum--; + if (shrinkIndex == -1) + break; + + listViewColumnWidths[shrinkIndex]--; } + + return listViewColumnWidths; + + // Calculate current total: sum of column widths + spaces between visible columns only + static int GetCurrentTotal(int[] widths) => widths.Sum() + Math.Max(0, widths.Count(w => w > 0) - 1); } #endregion diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json index 21108bf..e395a5c 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json @@ -14,7 +14,7 @@ "OCGV": { "commandName": "Executable", "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", - "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView}\"", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-NetAdapter | Out-ConsoleGridView}\"", "workingDirectory": "$(TargetDir)" }, "OCGV -MinUi": { @@ -34,6 +34,12 @@ "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)" } } } \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs index 790a731..7a6f3e7 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs @@ -6,6 +6,9 @@ using System.Globalization; using System.Linq; using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Text.RegularExpressions; +using Microsoft.PowerShell.Commands; using Microsoft.PowerShell.OutGridView.Models; namespace Microsoft.PowerShell.ConsoleGuiTools; @@ -16,79 +19,230 @@ namespace Microsoft.PowerShell.ConsoleGuiTools; /// 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); + /// - /// Gets the properties to display for the PowerShell objects using PowerShell's formatting system. + /// Strips ANSI escape sequences from a string. /// - /// The list of PowerShell objects to get format data for. - /// A list of property information representing the properties to display. - public static List GetFormatDataForObjects(List psObjects) + /// The string potentially containing ANSI codes. + /// The string with ANSI codes removed. + private static string StripAnsiCodes(string value) { - if (psObjects.Count == 0) + if (string.IsNullOrEmpty(value)) return value; + return AnsiEscapeRegex.Replace(value, string.Empty); + } + + /// + /// 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; + + // Create a runspace with the default initial session state to load format data + var iss = InitialSessionState.CreateDefault(); + using var runspace = RunspaceFactory.CreateRunspace(iss); + runspace.Open(); + + try + { + using var ps = System.Management.Automation.PowerShell.Create(); + ps.Runspace = runspace; + ps.AddCommand("Get-FormatData").AddParameter("TypeName", typeName); + + var results = ps.Invoke(); + + FormatViewDefinition? result = null; + if (results.Count > 0) + { + var extendedTypeDefinition = results[0].BaseObject as ExtendedTypeDefinition; + result = extendedTypeDefinition?.FormatViewDefinition.FirstOrDefault(v => v.Control is TableControl); + } + + _formatCache[typeName] = result; + return result; + } + finally { - return []; + runspace.Close(); } + } + + /// + /// 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) + { + string? typeName = obj.BaseObject.GetType().FullName; + if (typeName is null) return null; + + if (_formatCache.TryGetValue(typeName, out var cached)) + return cached; + + return GetFormatViewDefinitionForType(typeName); + } + + /// + /// Retrieves the column definitions for the specified PowerShell objects based on their format view definitions or properties. + /// + /// The list of PowerShell objects to analyze. + /// A distinct list of data table columns. + private List GetDataColumnsForObject(List psObjects) + { + var dataColumns = new List(); + + if (psObjects.Count == 0) return dataColumns; var firstObject = psObjects[0]; + var fvd = GetFormatViewDefinitionForObject(firstObject); - // Try to get the DefaultDisplayPropertySet from PSStandardMembers - var standardMembers = firstObject.Members["PSStandardMembers"]?.Value as PSMemberSet; - var defaultDisplayPropertySet = standardMembers?.Members["DefaultDisplayPropertySet"]?.Value as PSPropertySet; + List labels; + List propertyAccessors; - if (defaultDisplayPropertySet?.ReferencedPropertyNames != null && - defaultDisplayPropertySet.ReferencedPropertyNames.Count > 0) + if (fvd?.Control is TableControl tableControl) { - // Return only the properties in the DefaultDisplayPropertySet - return defaultDisplayPropertySet.ReferencedPropertyNames - .Select(name => firstObject.Properties[name]) - .Where(prop => prop != null) - .Cast() - .ToList(); + // Use the table format definition + 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 (string.IsNullOrEmpty(definedLabel)) return propLabel; + return definedLabel; + }).ToList(); + + propertyAccessors = displayEntries.Select(de => + de.ValueType == DisplayEntryValueType.Property + ? $"$_.\"{de.Value}\"" + : de.Value // ScriptBlock + ).ToList(); + } + else if (PSObjectIsPrimitive(firstObject)) + { + // Handle primitive types + labels = [firstObject.BaseObject.GetType().Name]; + propertyAccessors = ["$_"]; + } + else + { + // Fallback to properties + labels = firstObject.Properties.Select(p => p.Name).ToList(); + propertyAccessors = firstObject.Properties.Select(p => $"$_.\"{p.Name}\"").ToList(); + } + + for (int i = 0; i < labels.Count; i++) + { + var column = new DataTableColumn(labels[i], propertyAccessors[i]); + dataColumns.Add(column); } - // Fall back to all visible properties (excluding PS* internal properties) - return firstObject.Properties - .Where(p => !p.Name.StartsWith("PS", StringComparison.OrdinalIgnoreCase)) - .ToList(); + return dataColumns.Distinct().ToList(); } /// - /// Converts a PowerShell object to a data table row with values extracted based on the specified properties and columns. + /// 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. /// - /// The PowerShell object to convert. - /// The list of properties to extract. - /// The list of columns with format specifications. - /// The original index of the object in the source collection. - /// A containing the extracted values. - public static DataTableRow CastObjectToDataTableRow(PSObject psObject, List properties, List dataTableColumns, int objectIndex) + private static bool PSObjectIsPrimitive(PSObject ps) + { + var psBaseType = ps.BaseObject.GetType(); + return psBaseType.IsPrimitive || psBaseType.IsEnum || + ADDITIONAL_PRIMITIVE_TYPES.Contains(psBaseType.FullName!); + } + + /// + /// 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(); - for (int i = 0; i < properties.Count && i < dataTableColumns.Count; i++) + foreach (var column in dataTableColumns) { - var property = properties[i]; - var column = dataTableColumns[i]; - var propValue = psObject.Properties[property.Name]; - object? rawValue = null; + object? result = null; try { - rawValue = propValue?.Value; + // PSPropertyExpression constructor takes a ScriptBlock for script expressions + // For simple properties, we need to extract just the property name + var accessor = column.PropertyScriptAccessor; + + if (accessor.StartsWith("$_.\"") && accessor.EndsWith("\"")) + { + // 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) + { + result = psObjResult.BaseObject; + } + } + else if (accessor == "$_") + { + // The whole object + result = psObject.BaseObject; + } + else + { + // 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(); + + // Unwrap PSObject if needed + if (result is PSObject psScriptResult) + { + result = psScriptResult.BaseObject; + } + } } - catch + catch (Exception ex) { - // If property access fails, use null + // If evaluation fails, use null + result = null; } - // Use the column's FormatValue method to create the display string - var displayValue = column.FormatValue(rawValue); + // Convert to string and strip ANSI codes + var displayValue = result?.ToString() ?? string.Empty; + displayValue = StripAnsiCodes(displayValue); // Determine if this is a numeric value for sorting - var isDecimal = rawValue is decimal or int or long or short or byte or double or float; + 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; - if (isDecimal) + var columnKey = column.ToString(); + + if (isNumeric) { - var decimalValue = Convert.ToDecimal(rawValue, CultureInfo.InvariantCulture); - valuePairs[column.ToString()] = new DecimalValue + var decimalValue = Convert.ToDecimal(result, CultureInfo.InvariantCulture); + valuePairs[columnKey] = new DecimalValue { DisplayValue = displayValue, SortValue = decimalValue @@ -96,10 +250,10 @@ public static DataTableRow CastObjectToDataTableRow(PSObject psObject, List /// Sets the data type on each column based on the values in the data rows. - /// If all values in a column can be parsed as decimal, the column type is set to decimal; otherwise, it's set to - /// string. /// - /// The list of data table rows to analyze. - /// The list of data table columns to update with type information. private static void SetTypesOnDataColumns(List dataTableRows, List dataTableColumns) { var dataRows = dataTableRows.Select(x => x.Values); @@ -135,8 +285,6 @@ private static void SetTypesOnDataColumns(List dataTableRows, List /// /// 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. - /// A containing the columns and rows extracted from the PowerShell objects. public static DataTable CastObjectsToTableView(List psObjects) { if (psObjects.Count == 0) @@ -144,35 +292,15 @@ public static DataTable CastObjectsToTableView(List psObjects) return new DataTable([], []); } - // Get the properties to display using PowerShell's format data - var properties = GetFormatDataForObjects(psObjects); - - // Create columns from the properties with format strings based on property type - var dataTableColumns = properties - .Select(prop => - { - var column = new DataTableColumn(prop.Name, $"$_.{prop.Name}"); - - // Set format string based on property type - var propType = prop.TypeNameOfValue; - column.FormatString = propType switch - { - "System.DateTime" => "G", // General date/time - "System.Decimal" => "N2", // Decimal with 2 decimal places - "System.Int32" or "System.Int64" or "System.Int16" or "System.Byte" => "N0", // Integer numbers with thousand separators - "System.Double" or "System.Single" => "N2", // Floating point with 2 decimals - _ => null - }; - - return column; - }) - .ToList(); + // Get the columns using format view definitions + var typeGetter = new TypeGetter(); + var dataTableColumns = typeGetter.GetDataColumnsForObject(psObjects); // Convert each object to a row var dataTableRows = new List(); for (var i = 0; i < psObjects.Count; i++) { - var dataTableRow = CastObjectToDataTableRow(psObjects[i], properties, dataTableColumns, i); + var dataTableRow = CastObjectToDataTableRow(psObjects[i], dataTableColumns, i); dataTableRows.Add(dataTableRow); } diff --git a/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs b/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs index eb86293..755bea3 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs @@ -16,11 +16,6 @@ public class ApplicationData /// public List? PSObjects { get; set; } - /// - /// Gets or sets the PowerShell format data for the objects in . - /// - public List? FormatData { get; set; } - /// /// Gets or sets the output mode that determines how items can be selected and returned. /// @@ -31,7 +26,6 @@ public class ApplicationData /// public string? Title { get; set; } - /// /// Gets or sets the filter text to apply to the data. /// From aac40e6690f39e9573129a36a8c0238027e0a88f Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 15 Nov 2025 07:41:07 -0700 Subject: [PATCH 16/19] Moved header into ListView's Padding. Refactored the AddHeaders method to be an inline local function within AddListView, improving modularity and encapsulation. Removed the original AddHeaders implementation and its call from the constructor. The new AddHeaders method is conditionally invoked when _applicationData.MinUI is false. Updated the Y positioning of _listView to use _filterErrorView instead of _filterLabel. Adjusted _listView padding and scrollbar position to integrate the header. Changed MARGIN_LEFT constant from 1 to 0 to refine UI layout. --- .../OutGridViewWindow.cs | 70 +++++++++++-------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs index 10e9eab..aaf7d5d 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs @@ -23,7 +23,7 @@ namespace Microsoft.PowerShell.ConsoleGuiTools; internal sealed class OutGridViewWindow : Window { private const string FILTER_LABEL = "_Filter"; - private const int MARGIN_LEFT = 1; + private const int MARGIN_LEFT = 0; private const int CHECK_WIDTH = 2; private Label? _filterLabel; @@ -71,7 +71,6 @@ public OutGridViewWindow(ApplicationData applicationData) if (!_applicationData.MinUI) { AddFilter(); - AddHeaders(); } AddListView(); @@ -368,31 +367,6 @@ private void AddFilter() _filterField.CursorPosition = _filterField.Text.Length; } - /// - /// Adds the column header label and separator line to the window. - /// - private void AddHeaders() - { - _header = new View - { - Y = _applicationData.MinUI ? 0 : Pos.Bottom(_filterErrorView!), - Height = 1, - Width = Dim.Auto(DimAutoStyle.Text) - }; - Add(_header); - - if (!_applicationData.MinUI) - { - var headerLine = new Line - { - X = MARGIN_LEFT, - Y = Pos.Bottom(_header), - Width = Dim.Fill(MARGIN_LEFT) - }; - Add(headerLine); - } - } - /// /// Adds the main list view control to the window with configured selection behavior. /// @@ -402,7 +376,7 @@ private void AddListView() { Source = _inputSource, X = MARGIN_LEFT, - Y = !_applicationData.MinUI ? Pos.Bottom(_filterLabel!) + 2 : 1, + Y = !_applicationData.MinUI ? Pos.Bottom(_filterErrorView!) : 1, Width = Dim.Fill(), Height = Dim.Fill(1), AllowsMarking = _applicationData.OutputMode != OutputModeOption.None, @@ -414,9 +388,49 @@ private void AddListView() _listView.KeyBindings.Remove(Key.A.WithCtrl); + if (!_applicationData.MinUI) + { + AddHeaders(); + } + Add(_listView); + return; + + void AddHeaders() + { + _header = new View + { + //Y = _applicationData.MinUI ? 0 : Pos.Bottom(_filterErrorView!), + Height = 1, + Width = Dim.Auto(DimAutoStyle.Text) + }; + _header.GettingAttributeForRole += HeaderOnGettingAttributeForRole; + + _listView.ViewportChanged += ListViewOnViewportChanged; + + _listView.Padding!.Thickness = _listView.Padding.Thickness with { Top = 1 }; + _listView!.Padding!.Add(_header); + _listView.VerticalScrollBar.Y = 1; + return; + + void ListViewOnViewportChanged(object? sender, DrawEventArgs e) + { + _header.Viewport = _header.Viewport with { X = _listView.Viewport.X }; + } + + void HeaderOnGettingAttributeForRole(object? sender, VisualRoleEventArgs e) + { + if (e.Role == VisualRole.Normal) + { + e.Result = e.Result!.Value with { Style = TextStyle.Underline }; + e.Handled = true; + } + } + } } + + /// /// Adds the status bar with keyboard shortcuts to the window. /// From f14ce81a82b6fa12e991f46fd7c451ede97aa1d1 Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 15 Nov 2025 09:17:20 -0700 Subject: [PATCH 17/19] WIP: Added -AllProperties option Add AllProperties toggle to Out-ConsoleGridView cmdlet Introduced a new `AllProperties` parameter to the `Out-ConsoleGridView` cmdlet, allowing users to display all object properties instead of default display properties. Key changes: - Added `AllProperties` as a `SwitchParameter` in `OutConsoleGridViewCmdletCommand`. - Updated `ApplicationData` to store the `AllProperties` state. - Enhanced `OutGridViewWindow` with a `CheckBox` for toggling `AllProperties` and dynamic updates to the grid view and status bar. - Added `ReloadDataWithAllProperties` and `UpdateStatusBar` methods to handle UI updates. - Modified `TypeGetter` to support `AllProperties`, prioritizing format view definitions, default display property sets, and fallback to all properties. - Updated `CastObjectsToTableView` to include an `allProperties` parameter. - Improved compatibility with CIM instances by using the current runspace for format data retrieval. These changes enhance the cmdlet's flexibility and usability, particularly for inspecting all object properties. --- .../OutConsoleGridviewCmdletCommand.cs | 8 + .../OutGridViewWindow.cs | 97 ++++++++- .../TypeGetter.cs | 188 +++++++++++++----- .../ApplicationData.cs | 5 + 4 files changed, 249 insertions(+), 49 deletions(-) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs index b330d28..ce5e95b 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs @@ -75,6 +75,13 @@ public class OutConsoleGridViewCmdletCommand : PSCmdlet, IDisposable "If specified the Terminal.Gui System.Net.Console-based ConsoleDriver (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. /// @@ -165,6 +172,7 @@ protected override void EndProcessing() Filter = Filter, MinUI = MinUI, UseNetDriver = UseNetDriver, + AllProperties = AllProperties, Verbose = Verbose, Debug = Debug, ModuleVersion = MyInvocation.MyCommand.Version.ToString() diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs index aaf7d5d..7dfdc67 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs @@ -37,6 +37,7 @@ internal sealed class OutGridViewWindow : Window private readonly GridViewDetails _gridViewDetails; private readonly DataTable _dataTable; private int[]? _naturalColumnWidths; + private StatusBar? _statusBar; /// /// Initializes a new instance of the class with the specified application data. @@ -61,7 +62,7 @@ public OutGridViewWindow(ApplicationData applicationData) if (_applicationData.PSObjects is { Count: > 0 }) { var psObjects = _applicationData.PSObjects.Cast().ToList(); - _dataTable = TypeGetter.CastObjectsToTableView(psObjects); + _dataTable = TypeGetter.CastObjectsToTableView(psObjects, _applicationData.AllProperties); } else { @@ -291,6 +292,68 @@ private void ListViewSource_MarkChanged(object? s, GridViewDataSource.RowMarkedE #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 + if (_header is { }) + _header.Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails.ListViewOffset, + _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. /// @@ -477,6 +540,35 @@ private void AddStatusBar() })); shortcuts.Add(new Shortcut(Key.Esc, "Close", Close)); + + CheckBox allPropertiesCheckBox = new CheckBox() + { + Title = "A_ll Properties", + CheckedState = _applicationData.AllProperties ? CheckState.Checked : CheckState.UnChecked, + CanFocus = false, + }; + allPropertiesCheckBox.CheckedStateChanging += AllPropertiesCheckBoxOnCheckedStateChanging; + + void AllPropertiesCheckBoxOnCheckedStateChanging(object? sender, ResultEventArgs e) + { + + } + + allPropertiesCheckBox.CheckedStateChanged += AllPropertiesCheckBoxOnCheckedStateChanged; + + void AllPropertiesCheckBoxOnCheckedStateChanged(object? sender, EventArgs e) + { + ReloadDataWithAllProperties(!_applicationData.AllProperties); + } + + Shortcut allPropertiesShortcut = new Shortcut() + { + CommandView = allPropertiesCheckBox, + CanFocus = false, + BindKeyToApplication = true, + }; + shortcuts.Add(allPropertiesShortcut); + if (_applicationData.Verbose || _applicationData.Debug) { shortcuts.Add(new Shortcut(Key.Empty, $" v{_applicationData.ModuleVersion}", null)); @@ -485,7 +577,8 @@ private void AddStatusBar() null)); } - Add(new StatusBar(shortcuts)); + _statusBar = new StatusBar(shortcuts); + Add(_statusBar); } #endregion diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs index 7a6f3e7..e91c52d 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs @@ -1,6 +1,3 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - using System; using System.Collections.Generic; using System.Globalization; @@ -46,15 +43,12 @@ private static string StripAnsiCodes(string value) { if (_formatCache.TryGetValue(typeName, out var cached)) return cached; - // Create a runspace with the default initial session state to load format data - var iss = InitialSessionState.CreateDefault(); - using var runspace = RunspaceFactory.CreateRunspace(iss); - runspace.Open(); - try { - using var ps = System.Management.Automation.PowerShell.Create(); - ps.Runspace = runspace; + // 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 results = ps.Invoke(); @@ -69,9 +63,12 @@ private static string StripAnsiCodes(string value) _formatCache[typeName] = result; return result; } - finally + catch { - runspace.Close(); + // 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; } } @@ -82,63 +79,158 @@ private static string StripAnsiCodes(string value) /// The format view definition if found; otherwise, . private FormatViewDefinition? GetFormatViewDefinitionForObject(PSObject obj) { - string? typeName = obj.BaseObject.GetType().FullName; - if (typeName is null) return null; + // 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) + { + if (_formatCache.TryGetValue(typeName, out var cached)) + return cached; + + var fvd = GetFormatViewDefinitionForType(typeName); + if (fvd != null) + { + return fvd; + } + } + + // 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; + + return GetFormatViewDefinitionForType(baseTypeName); + } + + return null; + } + + /// + /// 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(); + } - if (_formatCache.TryGetValue(typeName, out var cached)) - return cached; + // 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) + { + return displayPropSet.ReferencedPropertyNames.ToList(); + } + } + } + catch + { + // If we can't get the default display property set, return null + } - return GetFormatViewDefinitionForType(typeName); + 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) + private List GetDataColumnsForObject(List psObjects, bool allProperties = false) { var dataColumns = new List(); if (psObjects.Count == 0) return dataColumns; var firstObject = psObjects[0]; - var fvd = GetFormatViewDefinitionForObject(firstObject); List labels; List propertyAccessors; - if (fvd?.Control is TableControl tableControl) + // If allProperties is requested, skip format view and default display property logic + if (allProperties) { - // Use the table format definition - 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 (PSObjectIsPrimitive(firstObject)) { - 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 (PSObjectIsPrimitive(firstObject)) - { - // Handle primitive types - labels = [firstObject.BaseObject.GetType().Name]; - propertyAccessors = ["$_"]; + // Handle primitive types + labels = [firstObject.BaseObject.GetType().Name]; + propertyAccessors = ["$_"]; + } + else + { + // Return all properties + labels = firstObject.Properties.Select(p => p.Name).ToList(); + propertyAccessors = firstObject.Properties.Select(p => $"$_.\"{p.Name}\"").ToList(); + } } else { - // Fallback to properties - labels = firstObject.Properties.Select(p => p.Name).ToList(); - propertyAccessors = firstObject.Properties.Select(p => $"$_.\"{p.Name}\"").ToList(); + // 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 (fvd?.Control is TableControl tableControl) + { + // 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 (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(); + } } for (int i = 0; i < labels.Count; i++) @@ -285,7 +377,9 @@ private static void SetTypesOnDataColumns(List dataTableRows, List /// /// Converts a list of PowerShell objects into a data table structure suitable for display in the grid view. /// - public static DataTable CastObjectsToTableView(List psObjects) + /// 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) { @@ -294,7 +388,7 @@ public static DataTable CastObjectsToTableView(List psObjects) // Get the columns using format view definitions var typeGetter = new TypeGetter(); - var dataTableColumns = typeGetter.GetDataColumnsForObject(psObjects); + var dataTableColumns = typeGetter.GetDataColumnsForObject(psObjects, allProperties); // Convert each object to a row var dataTableRows = new List(); diff --git a/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs b/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs index 755bea3..fe56ff6 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs @@ -41,6 +41,11 @@ public class ApplicationData /// public bool UseNetDriver { get; set; } + /// + /// Gets or sets a value indicating whether all properties should be displayed. + /// + public bool AllProperties { get; set; } + /// /// Gets or sets a value indicating whether verbose output is enabled. /// From 71cca6481b1a8d50344bd487e2a9debc08293199 Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 15 Nov 2025 14:20:19 -0700 Subject: [PATCH 18/19] Better use of v2 tech + added -AllProperties Refactored `OutConsoleGridViewCmdletCommand` and `OutGridViewWindow` classes for improved readability, maintainability, and performance. Introduced a new `Header` class for dynamic grid column headers with bold and underlined styles. Simplified logic, removed redundant methods, and optimized nullability handling. Enhanced `TypeGetter` to filter out expensive `PS*` metadata properties, improving performance. Updated `ApplicationData` to clarify `AllProperties` behavior and removed unused imports. Added a new launch configuration in `launchSettings.json` for testing the `-AllProperties` parameter. Improved status bar shortcuts and event handling for better user experience. Updated comments and documentation for consistency. --- .../Header.cs | 99 +++++++ .../OutConsoleGridviewCmdletCommand.cs | 6 +- .../OutGridViewWindow.cs | 274 ++++-------------- .../Properties/launchSettings.json | 6 + .../TypeGetter.cs | 11 +- .../ApplicationData.cs | 4 +- 6 files changed, 167 insertions(+), 233 deletions(-) create mode 100644 src/Microsoft.PowerShell.ConsoleGuiTools/Header.cs 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/OutConsoleGridviewCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs index ce5e95b..8e3cce9 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs @@ -67,12 +67,12 @@ public class OutConsoleGridViewCmdletCommand : PSCmdlet, IDisposable public SwitchParameter MinUI { set; get; } /// - /// Gets or sets a value indicating whether the Terminal.Gui System.Net.Console-based ConsoleDriver will be used + /// 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) ConsoleDriver. + /// default platform-specific (Windows or Curses) Driver. /// [Parameter(HelpMessage = - "If specified the Terminal.Gui System.Net.Console-based ConsoleDriver (NetDriver) will be used.")] + "If specified the Terminal.Gui System.Net.Console-based Driver (NetDriver) will be used.")] public SwitchParameter UseNetDriver { set; get; } /// diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs index 7dfdc67..b15104e 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; -using System.Globalization; using System.Linq; using System.Management.Automation; using System.Reflection; @@ -25,17 +24,17 @@ 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 TextField? _filterField; - private View? _filterErrorView; - private View? _header; - private ListView? _listView; + private Header? _header; private GridViewDataSource? _inputSource; + private ListView? _listView; private GridViewDataSource? _listViewSource; - private readonly ApplicationData _applicationData; - private readonly GridViewDetails _gridViewDetails; - private readonly DataTable _dataTable; private int[]? _naturalColumnWidths; private StatusBar? _statusBar; @@ -69,70 +68,20 @@ public OutGridViewWindow(ApplicationData applicationData) _dataTable = new DataTable([], []); } - if (!_applicationData.MinUI) - { - AddFilter(); - } + 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(); - if (_header is { }) - _header.Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails.ListViewOffset, - _gridViewDetails.ListViewColumnWidths); - - } - - /// - /// Determines if a property name represents an identifier rather than a quantity. - /// Identifiers should not have thousand separators. - /// - /// The name of the property. - /// True if the property is likely an identifier; otherwise false. - private static bool IsIdentifierProperty(string propertyName) - { - // Common identifier property names - return propertyName.Equals("Id", StringComparison.OrdinalIgnoreCase) || - propertyName.EndsWith("Id", StringComparison.OrdinalIgnoreCase) || - propertyName.Equals("PID", StringComparison.OrdinalIgnoreCase) || - propertyName.Equals("ProcessId", StringComparison.OrdinalIgnoreCase) || - propertyName.Equals("SessionId", StringComparison.OrdinalIgnoreCase) || - propertyName.Equals("SI", StringComparison.OrdinalIgnoreCase) || - propertyName.Equals("ParentProcessId", StringComparison.OrdinalIgnoreCase) || - propertyName.Equals("ThreadId", StringComparison.OrdinalIgnoreCase); - } - - /// - /// Sets the data type on each column based on the values in the data rows. - /// If all values in a column can be parsed as decimal, the column type is set to decimal; otherwise, it's set to - /// string. - /// - /// The list of data table rows to analyze. - /// The list of data table columns to update with type information. - 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; - - // 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) - { - if (dataRow[dataColumn.ToString()] is not DecimalValue) - dataColumn.StringType = typeof(string).FullName; - } - } + _header?.SetHeaders(gridHeaders, _gridViewDetails.ListViewColumnWidths); } /// @@ -165,15 +114,14 @@ public HashSet GetSelectedIndexes() private GridViewDataSource LoadData() { var items = new List(); - if (_dataTable.Data.Count == 0) + if (_dataTable?.Data.Count == 0) return new GridViewDataSource(items); // Calculate and cache natural column widths - var gridHeaders = _dataTable.DataColumns.Select(c => c.Label).ToList(); - _naturalColumnWidths = CalculateNaturalColumnWidths(gridHeaders); + _naturalColumnWidths = CalculateNaturalColumnWidths(_dataTable?.DataColumns.Select(c => c.Label).ToList()); _gridViewDetails.ListViewColumnWidths = _naturalColumnWidths; - for (var i = 0; i < _dataTable.Data.Count; i++) + for (var i = 0; i < _dataTable?.Data.Count; i++) { var dataTableRow = _dataTable.Data[i]; var valueList = new List(); @@ -182,16 +130,11 @@ private GridViewDataSource LoadData() var columnKey = dataTableColumn.ToString(); // Check if the key exists in the dictionary - if (dataTableRow.Values.TryGetValue(columnKey, out var value)) - { - valueList.Add(value.DisplayValue); - } - else - { + 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 - valueList.Add(string.Empty); - } + : string.Empty); } var displayString = GridViewHelpers.GetPaddedString(valueList, 0, _gridViewDetails.ListViewColumnWidths); @@ -206,28 +149,6 @@ private GridViewDataSource LoadData() return new GridViewDataSource(items); } - /// - /// Updates the display strings for all rows in the specified data source based on current column widths. - /// - /// The data source containing rows to update. - private void UpdateDisplayStrings(GridViewDataSource? source) - { - if (source == null) return; - - foreach (var gvr in source.GridViewRowList) - { - var valueList = new List(); - var dataTableRow = _dataTable.Data[gvr.OriginalIndex]; - foreach (var dataTableColumn in _dataTable.DataColumns) - { - var dataValue = dataTableRow.Values[dataTableColumn.ToString()].DisplayValue; - valueList.Add(dataValue); - } - - gvr.DisplayString = GridViewHelpers.GetPaddedString(valueList, 0, _gridViewDetails.ListViewColumnWidths); - } - } - #endregion #region Filtering @@ -242,7 +163,7 @@ private void ApplyFilter() if (_listViewSource != null) { selectedItem = _listViewSource.GridViewRowList.ElementAtOrDefault(_listView?.SelectedItem ?? 0); - _listViewSource.MarkChanged -= ListViewSource_MarkChanged; + _listViewSource.MarkChanged -= OnListViewSourceMarkChanged; _listViewSource = null; } @@ -259,13 +180,11 @@ private void ApplyFilter() _filterErrorView.Text = ex.Message; } - if (_listViewSource != null) - _listViewSource.MarkChanged += ListViewSource_MarkChanged; + _listViewSource?.MarkChanged += OnListViewSourceMarkChanged; - if (_listView != null) - _listView.Source = _listViewSource; + _listView?.Source = _listViewSource; - if (selectedItem is { } && _listViewSource != null) + if (selectedItem is not null && _listViewSource != null) { var newIndex = _listViewSource.GridViewRowList.FindIndex(i => i.OriginalIndex == selectedItem.OriginalIndex); @@ -282,7 +201,7 @@ private void ApplyFilter() /// /// The event sender. /// The event arguments containing the row that was marked or unmarked. - private void ListViewSource_MarkChanged(object? s, GridViewDataSource.RowMarkedEventArgs a) + private void OnListViewSourceMarkChanged(object? s, GridViewDataSource.RowMarkedEventArgs a) { if (_inputSource != null) _inputSource.GridViewRowList[a.Row.OriginalIndex].IsMarked = a.Row.IsMarked; @@ -298,7 +217,7 @@ private void ListViewSource_MarkChanged(object? s, GridViewDataSource.RowMarkedE 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 }) @@ -323,20 +242,18 @@ private void ReloadDataWithAllProperties(bool allProperties) _gridViewDetails.UsableWidth = _naturalColumnWidths.Sum(); // Update header - if (_header is { }) - _header.Text = GridViewHelpers.GetPaddedString(gridHeaders, _gridViewDetails.ListViewOffset, - _gridViewDetails.ListViewColumnWidths); + _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(); @@ -451,49 +368,26 @@ private void AddListView() _listView.KeyBindings.Remove(Key.A.WithCtrl); - if (!_applicationData.MinUI) - { - AddHeaders(); - } + if (!_applicationData.MinUI) AddHeader(); Add(_listView); return; - - void AddHeaders() + + void AddHeader() { - _header = new View + _header = new Header { - //Y = _applicationData.MinUI ? 0 : Pos.Bottom(_filterErrorView!), - Height = 1, - Width = Dim.Auto(DimAutoStyle.Text) + X = CHECK_WIDTH }; - _header.GettingAttributeForRole += HeaderOnGettingAttributeForRole; - _listView.ViewportChanged += ListViewOnViewportChanged; _listView.Padding!.Thickness = _listView.Padding.Thickness with { Top = 1 }; _listView!.Padding!.Add(_header); _listView.VerticalScrollBar.Y = 1; - return; - - void ListViewOnViewportChanged(object? sender, DrawEventArgs e) - { - _header.Viewport = _header.Viewport with { X = _listView.Viewport.X }; - } - - void HeaderOnGettingAttributeForRole(object? sender, VisualRoleEventArgs e) - { - if (e.Role == VisualRole.Normal) - { - e.Result = e.Result!.Value with { Style = TextStyle.Underline }; - e.Handled = true; - } - } } } - /// /// Adds the status bar with keyboard shortcuts to the window. /// @@ -501,17 +395,17 @@ private void AddStatusBar() { var shortcuts = new List(); if (_applicationData.OutputMode != OutputModeOption.None) - shortcuts.Add(new Shortcut(Key.Space, "Select Item", null)); + shortcuts.Add(new Shortcut(Key.Space, "Select", null)); if (_applicationData.OutputMode == OutputModeOption.Multiple) { - shortcuts.Add(new Shortcut(Key.A.WithCtrl, "Select All", () => + shortcuts.Add(new Shortcut(Key.A.WithCtrl, "Sel. All", () => { _listView?.MarkAll(true); _listView?.SetNeedsDraw(); })); - shortcuts.Add(new Shortcut(Key.D.WithCtrl, "Select None", () => + shortcuts.Add(new Shortcut(Key.D.WithCtrl, "Sel. None", () => { _listView?.MarkAll(false); _listView?.SetNeedsDraw(); @@ -541,34 +435,28 @@ private void AddStatusBar() shortcuts.Add(new Shortcut(Key.Esc, "Close", Close)); - CheckBox allPropertiesCheckBox = new CheckBox() + var allPropertiesShortcut = new Shortcut { - Title = "A_ll Properties", - CheckedState = _applicationData.AllProperties ? CheckState.Checked : CheckState.UnChecked, + CommandView = new CheckBox + { + Title = "A_ll Properties", + CheckedState = _applicationData.AllProperties ? CheckState.Checked : CheckState.UnChecked, + CanFocus = false, + HighlightStates = MouseState.None + }, CanFocus = false, + BindKeyToApplication = true }; - allPropertiesCheckBox.CheckedStateChanging += AllPropertiesCheckBoxOnCheckedStateChanging; - void AllPropertiesCheckBoxOnCheckedStateChanging(object? sender, ResultEventArgs e) - { - - } - - allPropertiesCheckBox.CheckedStateChanged += AllPropertiesCheckBoxOnCheckedStateChanged; - - void AllPropertiesCheckBoxOnCheckedStateChanged(object? sender, EventArgs e) + allPropertiesShortcut.Accepting += (_, e) => { ReloadDataWithAllProperties(!_applicationData.AllProperties); - } - - Shortcut allPropertiesShortcut = new Shortcut() - { - CommandView = allPropertiesCheckBox, - CanFocus = false, - BindKeyToApplication = true, + e.Handled = true; }; + shortcuts.Add(allPropertiesShortcut); - + + if (_applicationData.Verbose || _applicationData.Debug) { shortcuts.Add(new Shortcut(Key.Empty, $" v{_applicationData.ModuleVersion}", null)); @@ -585,31 +473,6 @@ void AllPropertiesCheckBoxOnCheckedStateChanged(object? sender, EventArgs - /// Handles layout of subviews by calculating column widths and applying the current filter. - /// - /// The layout event arguments. - protected override void OnSubViewLayout(LayoutEventArgs args) - { - // Create the headers and calculate column widths based on the DataTable - - - //if (_naturalColumnWidths!.Sum() > Viewport.Width - 1) - //{ - // _listView!.HorizontalScrollBar.Visible = true; - //} - //else - //{ - // _listView!.HorizontalScrollBar.Visible = false; - //} - //UpdateDisplayStrings(_listViewSource); - - //ApplyFilter(); - base.OnSubViewLayout(args); - - } - protected override void OnSubViewsLaidOut(LayoutEventArgs args) { base.OnSubViewsLaidOut(args); @@ -623,7 +486,7 @@ protected override void OnSubViewsLaidOut(LayoutEventArgs args) /// An array of column widths where each width is the maximum needed for that column. private int[] CalculateNaturalColumnWidths(List? gridHeaders) { - if (gridHeaders == null) + if (gridHeaders is null || _dataTable is null) return []; var columnWidths = new int[gridHeaders.Count]; @@ -634,7 +497,6 @@ private int[] CalculateNaturalColumnWidths(List? gridHeaders) // 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(); @@ -645,47 +507,9 @@ private int[] CalculateNaturalColumnWidths(List? gridHeaders) columnWidths[i] = len; } } - } return columnWidths; } - /// - /// Calculates optimal column widths based on header and data content, fitting within the available screen width. - /// - /// The column headers for the grid. - /// - /// If the column widths could not be calculated. - private int[]? CalculateColumnWidths(List? gridHeaders, int width) - { - if (gridHeaders == null) return null; - - var listViewColumnWidths = _naturalColumnWidths; - - while (GetCurrentTotal(listViewColumnWidths) > width) - { - // Find the rightmost column with width > 0 and shrink it - var shrinkIndex = -1; - for (var i = listViewColumnWidths.Length - 1; i >= 0; i--) - { - if (listViewColumnWidths[i] > 0) - { - shrinkIndex = i; - break; - } - } - - if (shrinkIndex == -1) - break; - - listViewColumnWidths[shrinkIndex]--; - } - - return listViewColumnWidths; - - // Calculate current total: sum of column widths + spaces between visible columns only - static int GetCurrentTotal(int[] widths) => widths.Sum() + Math.Max(0, widths.Count(w => w > 0) - 1); - } - #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 index e395a5c..ca80d07 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json @@ -40,6 +40,12 @@ "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/TypeGetter.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs index e91c52d..3856ea1 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs @@ -178,9 +178,14 @@ private List GetDataColumnsForObject(List psObjects, } else { - // Return all properties - labels = firstObject.Properties.Select(p => p.Name).ToList(); - propertyAccessors = firstObject.Properties.Select(p => $"$_.\"{p.Name}\"").ToList(); + // 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 diff --git a/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs b/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs index fe56ff6..e4e18e8 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System.Collections.Generic; -using System.Management.Automation; namespace Microsoft.PowerShell.OutGridView.Models; @@ -42,7 +41,8 @@ public class ApplicationData public bool UseNetDriver { get; set; } /// - /// Gets or sets a value indicating whether all properties should be displayed. + /// 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; } From 1432452fa25add59e6939a1ddb9cee60faa87d6b Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 15 Nov 2025 16:18:14 -0700 Subject: [PATCH 19/19] Ensure explicit List conversion for dataTableColumns Updated the assignment of `dataTableColumns` to explicitly convert the result of `typeGetter.GetDataColumnsForObject` into a `List` using `.ToList()`. This change ensures compatibility and enables list-specific operations, improving code clarity and robustness. --- src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs index 3856ea1..3500651 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs @@ -393,7 +393,7 @@ public static DataTable CastObjectsToTableView(List psObjects, bool al // Get the columns using format view definitions var typeGetter = new TypeGetter(); - var dataTableColumns = typeGetter.GetDataColumnsForObject(psObjects, allProperties); + List dataTableColumns = typeGetter.GetDataColumnsForObject(psObjects, allProperties).ToList(); // Convert each object to a row var dataTableRows = new List();