diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index b20a0ce..830729c 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -34,7 +34,7 @@ jobs: - name: Build and test shell: pwsh - run: Invoke-Build -Configuration Release Build, Package + run: Invoke-Build -Configuration Release Build, Test, Package - name: Upload module if: always() diff --git a/.gitignore b/.gitignore index 80046ed..4f43da1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,13 @@ bin/ obj/ publish/ *.sln +*.slnx + +# IDE support (keep locally, don't track) +/.vs/ +/.vscode/ +*.user +*.suo +*.sln.DotSettings +Properties/launchSettings.json +nuget.local.config diff --git a/ConsoleGuiTools.build.ps1 b/ConsoleGuiTools.build.ps1 index 3fa225b..8ac146a 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 = $( @@ -45,6 +48,10 @@ task Build { New-ExternalHelp -Path docs/Microsoft.PowerShell.ConsoleGuiTools -OutputPath module/en-US -Force } +task Test { + Invoke-BuildExec { & dotnet test --configuration $Configuration } +} + task Package { New-Item -ItemType Directory -Force ./out | Out-Null if (-Not (Get-PSResourceRepository -Name ConsoleGuiTools -ErrorAction SilentlyContinue)) { @@ -53,4 +60,4 @@ task Package { Publish-PSResource -Path ./module -Repository ConsoleGuiTools -Verbose } -task . Clean, Build +task . Clean, Build, Test diff --git a/Directory.Build.props b/Directory.Build.props index 1932808..0f11888 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,8 @@ true + + $(MSBuildThisFileDirectory)nuget.local.config diff --git a/Directory.Packages.props b/Directory.Packages.props index be0590b..a1ee8dc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,8 +1,13 @@ - - - - + + + + + + + + + - + \ No newline at end of file diff --git a/GraphicalTools.sln b/GraphicalTools.sln new file mode 100644 index 0000000..5f97c02 --- /dev/null +++ b/GraphicalTools.sln @@ -0,0 +1,71 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +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", "{DBAF2B0C-2E02-4698-A788-EB7EB6364404}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.PowerShell.OutGridView.Models", "src\Microsoft.PowerShell.OutGridView.Models\Microsoft.PowerShell.OutGridView.Models.csproj", "{233472F8-D472-4265-813B-B394013A2B60}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.PowerShell.ConsoleGuiTools.Tests", "test\Microsoft.PowerShell.ConsoleGuiTools.Tests\Microsoft.PowerShell.ConsoleGuiTools.Tests.csproj", "{F1E2D3C4-B5A6-7890-ABCD-EF0987654321}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DBAF2B0C-2E02-4698-A788-EB7EB6364404}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DBAF2B0C-2E02-4698-A788-EB7EB6364404}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DBAF2B0C-2E02-4698-A788-EB7EB6364404}.Debug|x64.ActiveCfg = Debug|Any CPU + {DBAF2B0C-2E02-4698-A788-EB7EB6364404}.Debug|x64.Build.0 = Debug|Any CPU + {DBAF2B0C-2E02-4698-A788-EB7EB6364404}.Debug|x86.ActiveCfg = Debug|Any CPU + {DBAF2B0C-2E02-4698-A788-EB7EB6364404}.Debug|x86.Build.0 = Debug|Any CPU + {DBAF2B0C-2E02-4698-A788-EB7EB6364404}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DBAF2B0C-2E02-4698-A788-EB7EB6364404}.Release|Any CPU.Build.0 = Release|Any CPU + {DBAF2B0C-2E02-4698-A788-EB7EB6364404}.Release|x64.ActiveCfg = Release|Any CPU + {DBAF2B0C-2E02-4698-A788-EB7EB6364404}.Release|x64.Build.0 = Release|Any CPU + {DBAF2B0C-2E02-4698-A788-EB7EB6364404}.Release|x86.ActiveCfg = Release|Any CPU + {DBAF2B0C-2E02-4698-A788-EB7EB6364404}.Release|x86.Build.0 = Release|Any CPU + {233472F8-D472-4265-813B-B394013A2B60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {233472F8-D472-4265-813B-B394013A2B60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {233472F8-D472-4265-813B-B394013A2B60}.Debug|x64.ActiveCfg = Debug|Any CPU + {233472F8-D472-4265-813B-B394013A2B60}.Debug|x64.Build.0 = Debug|Any CPU + {233472F8-D472-4265-813B-B394013A2B60}.Debug|x86.ActiveCfg = Debug|Any CPU + {233472F8-D472-4265-813B-B394013A2B60}.Debug|x86.Build.0 = Debug|Any CPU + {233472F8-D472-4265-813B-B394013A2B60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {233472F8-D472-4265-813B-B394013A2B60}.Release|Any CPU.Build.0 = Release|Any CPU + {233472F8-D472-4265-813B-B394013A2B60}.Release|x64.ActiveCfg = Release|Any CPU + {233472F8-D472-4265-813B-B394013A2B60}.Release|x64.Build.0 = Release|Any CPU + {233472F8-D472-4265-813B-B394013A2B60}.Release|x86.ActiveCfg = Release|Any CPU + {233472F8-D472-4265-813B-B394013A2B60}.Release|x86.Build.0 = Release|Any CPU + {F1E2D3C4-B5A6-7890-ABCD-EF0987654321}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F1E2D3C4-B5A6-7890-ABCD-EF0987654321}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F1E2D3C4-B5A6-7890-ABCD-EF0987654321}.Debug|x64.ActiveCfg = Debug|Any CPU + {F1E2D3C4-B5A6-7890-ABCD-EF0987654321}.Debug|x64.Build.0 = Debug|Any CPU + {F1E2D3C4-B5A6-7890-ABCD-EF0987654321}.Debug|x86.ActiveCfg = Debug|Any CPU + {F1E2D3C4-B5A6-7890-ABCD-EF0987654321}.Debug|x86.Build.0 = Debug|Any CPU + {F1E2D3C4-B5A6-7890-ABCD-EF0987654321}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F1E2D3C4-B5A6-7890-ABCD-EF0987654321}.Release|Any CPU.Build.0 = Release|Any CPU + {F1E2D3C4-B5A6-7890-ABCD-EF0987654321}.Release|x64.ActiveCfg = Release|Any CPU + {F1E2D3C4-B5A6-7890-ABCD-EF0987654321}.Release|x64.Build.0 = Release|Any CPU + {F1E2D3C4-B5A6-7890-ABCD-EF0987654321}.Release|x86.ActiveCfg = Release|Any CPU + {F1E2D3C4-B5A6-7890-ABCD-EF0987654321}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {DBAF2B0C-2E02-4698-A788-EB7EB6364404} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {233472F8-D472-4265-813B-B394013A2B60} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {F1E2D3C4-B5A6-7890-ABCD-EF0987654321} = {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 71bf677..69559ab 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Whatever was typed on the command line prior to hitting `F7` or `Shift-F7` will ### Example 8: Output processes to a tree view ```PowerShell -PS C:\> Get-Process | Show-ObjectTree +Get-Process | Show-ObjectTree ``` This command gets the processes running on the local computer and sends them to a tree view window. @@ -131,9 +131,9 @@ Use right arrow when a row has a `+` symbol to expand the tree. Left arrow will ## Development -### 1. Install PowerShell 7.2+ +### 1. Install PowerShell 7.6+ -Install PowerShell 7.2+ with [these instructions](https://github.com/PowerShell/PowerShell#get-powershell). +Install PowerShell 7.6+ with [these instructions](https://github.com/PowerShell/PowerShell#get-powershell). ### 2. Clone the GitHub repository diff --git a/docs/Microsoft.PowerShell.ConsoleGuiTools/Out-ConsoleGridView.md b/docs/Microsoft.PowerShell.ConsoleGuiTools/Out-ConsoleGridView.md index 895bce4..3aeba48 100644 --- a/docs/Microsoft.PowerShell.ConsoleGuiTools/Out-ConsoleGridView.md +++ b/docs/Microsoft.PowerShell.ConsoleGuiTools/Out-ConsoleGridView.md @@ -25,9 +25,7 @@ Sends output to an interactive table in the same console window. The **Out-ConsoleGridView** cmdlet sends the output from a command to a grid view window where the output is displayed in an interactive table. -You can use the following features of the table to examine your data: - -- Quick Filter. Use the Filter box at the top of the window to search the text in the table. You can search for text in a particular column, search for literals, and search for multiple words. You can use the `-Filter` command to pre-populate the Filter box. The filter uses regular expressions. +Use the Filter box at the top of the window to search the text in the table. Search for text in a particular column, search for literals, and search for multiple words. Use the `-Filter` parameter to pre-populate the Filter box. The filter uses regular expressions. For instructions for using these features, type `Get-Help Out-ConsoleGridView -Full` and see How to Use the Grid View Window Features in the Notes section. @@ -38,7 +36,7 @@ To send items from the interactive window down the pipeline, click to select the ### Example 1: Output processes to a grid view ```PowerShell -PS C:\> Get-Process | Out-ConsoleGridView +Get-Process | Out-ConsoleGridView ``` This command gets the processes running on the local computer and sends them to a grid view window. @@ -46,8 +44,8 @@ This command gets the processes running on the local computer and sends them to ### Example 2: Use a variable to output processes to a grid view ```PowerShell -PS C:\> $P = Get-Process -PS C:\> $P | Out-ConsoleGridView -OutputMode Single +$P = Get-Process +$P | Out-ConsoleGridView -OutputMode Single ``` This command also gets the processes running on the local computer and sends them to a grid view window. @@ -61,7 +59,7 @@ By specifying `-OutputMode Single` the grid view window will be restricted to a ### Example 3: Display a formatted table in a grid view ```PowerShell -PS C:\> Get-Process | Select-Object -Property Name, WorkingSet, PeakWorkingSet | Sort-Object -Property WorkingSet -Descending | Out-ConsoleGridView +Get-Process | Select-Object -Property Name, WorkingSet, PeakWorkingSet | Sort-Object -Property WorkingSet -Descending | Out-ConsoleGridView ``` This command displays a formatted table in a grid view window. @@ -80,7 +78,7 @@ You can now use the features of the grid view to search, sort, and filter the da ### Example 4: Save output to a variable, and then output a grid view ```PowerShell -PS C:\> ($A = Get-ChildItem -Path $pshome -Recurse) | Out-ConsoleGridView +($A = Get-ChildItem -Path $pshome -Recurse) | Out-ConsoleGridView ``` This command saves its output in a variable and sends it to **Out-ConsoleGridView**. @@ -96,7 +94,7 @@ As a result, the output from the Get-ChildItem command is saved in the $A variab ### Example 5: Output processes for a specified computer to a grid view ```PowerShell -PS C:\> Get-Process -ComputerName "Server01" | ocgv -Title "Processes - Server01" +Get-Process -ComputerName "Server01" | ocgv -Title "Processes - Server01" ``` This command displays the processes that are running on the Server01 computer in a grid view window. @@ -106,17 +104,17 @@ The command uses `ocgv`, which is the built-in alias for the **Out-ConsoleGridVi ### Example 6: Define a function to kill processes using a graphical chooser ```PowerShell -PS C:\> function killp { Get-Process | Out-ConsoleGridView -OutputMode Single -Filter $args[0] | Stop-Process -Id {$_.Id} } -PS C:\> killp note +function killp { Get-Process | Out-ConsoleGridView -OutputMode Single -Filter $args[0] | Stop-Process -Id {$_.Id} } +killp note ``` This example shows defining a function named `killp` that shows a grid view of all running processes and allows the user to select one to kill it. -The example uses the `-Filter` paramter to filter for all proceses with a name that includes `note` (thus highlighting `Notepad` if it were running. Selecting an item in the grid view and pressing `ENTER` will kill that process. +The example uses the `-Filter` parameter to filter for all proceses with a name that includes `note` (thus highlighting `Notepad` if it were running. Selecting an item in the grid view and pressing `ENTER` will kill that process. ### Example 7: Pass multiple items through Out-ConsoleGridView ```PowerShell -PS C:\> Get-Process | Out-ConsoleGridView -PassThru | Export-Csv -Path .\ProcessLog.csv +Get-Process | Out-ConsoleGridView -PassThru | Export-Csv -Path .\ProcessLog.csv ``` This command lets you select multiple processes from the **Out-ConsoleGridView** window. diff --git a/docs/Microsoft.PowerShell.ConsoleGuiTools/Show-ObjectTree.md b/docs/Microsoft.PowerShell.ConsoleGuiTools/Show-ObjectTree.md index d7bde95..b6f36d8 100644 --- a/docs/Microsoft.PowerShell.ConsoleGuiTools/Show-ObjectTree.md +++ b/docs/Microsoft.PowerShell.ConsoleGuiTools/Show-ObjectTree.md @@ -24,9 +24,7 @@ Show-ObjectTree [-InputObject ] [-Title ] [-Filter ] [ The **Show-ObjectTree** cmdlet sends the output from a command to a tree view window where the output is displayed in an interactive tree. -You can use the following features of the tree to examine your data: - -- Quick Filter. Use the Filter box at the top of the window to search the text in the tree. You can search for literals or multiple words. You can use the `-Filter` command to pre-populate the Filter box. The filter uses regular expressions. +Use the Filter box at the top of the window to search the text in the tree including literals or multiple words. Use the `-Filter` parameter to pre-populate the Filter box. The filter uses regular expressions. For instructions for using these features, type `Get-Help Show-ObjectTree -Full` and see How to Use the Tree View Window Features in the Notes section. @@ -35,7 +33,7 @@ For instructions for using these features, type `Get-Help Show-ObjectTree -Full` ### Example 1: Output processes to a tree view ```PowerShell -PS C:\> Get-Process | Show-ObjectTree +Get-Process | Show-ObjectTree ``` This command gets the processes running on the local computer and sends them to a tree view window. @@ -43,12 +41,13 @@ This command gets the processes running on the local computer and sends them to ### Example 2: Save output to a variable, and then output a tree view ```PowerShell -PS C:\> ($A = Get-ChildItem -Path $pshome -Recurse) | shot +($A = Get-ChildItem -Path $pshome -Recurse) | shot ``` This command saves its output in a variable and sends it to **Show-ObjectTree**. The command uses the Get-ChildItem cmdlet to get the files in the Windows PowerShell installation directory and its subdirectories. + The path to the installation directory is saved in the $pshome automatic variable. The command uses the assignment operator (=) to save the output in the $A variable and the pipeline operator (|) to send the output to **Show-ObjectTree**. @@ -76,7 +75,7 @@ Accept wildcard characters: False ### -InputObject Specifies that the cmdlet accepts input for **Show-ObjectTree**. -When you use the **InputObject** parameter to send a collection of objects to **Show-ObjectTree**, **Show-ObjectTree** treats the collection as one collection object, and it displays one row that represents the collection. +The **InputObject** parameter enables sending a collection of objects to **Show-ObjectTree**, **Show-ObjectTree** treats the collection as one collection object, and it displays one row that represents the collection. To display the each object in the collection, use a pipeline operator (|) to send objects to **Show-ObjectTree**. @@ -141,7 +140,7 @@ You can send any object to this cmdlet. ## NOTES -* The command output that you send to **Show-ObjectTree** should not be formatted, such as by using the Format-Table or Format-Wide cmdlets. To select properties, use the Select-Object cmdlet. +* The command output sent to **Show-ObjectTree** should not be formatted, such as by using the Format-Table or Format-Wide cmdlets. To select properties, use the Select-Object cmdlet. * Deserialized output from remote commands might not be formatted correctly in the tree view window. diff --git a/docs/OutTableView-Plan.md b/docs/OutTableView-Plan.md new file mode 100644 index 0000000..34bb8dc --- /dev/null +++ b/docs/OutTableView-Plan.md @@ -0,0 +1,275 @@ +# OutTableView Implementation Plan + +## Overview + +Create a new `Out-ConsoleTableView` (OCTV) cmdlet that mirrors `Out-ConsoleGridView` (OCGV) functionality but uses Terminal.Gui's `TableView` instead of `ListView`. This addresses **Issue #209** — streaming pipeline input so rows appear as they arrive (in `ProcessRecord`) rather than waiting until `EndProcessing`. + +## Why TableView? + +The current OCGV uses `ListView` with a custom `GridViewDataSource` and manual column formatting (`GridViewHelpers.GetPaddedString`, a custom `Header` view, etc.). Terminal.Gui's `TableView` provides all of this natively: + +- Built-in column headers with sticky scrolling (`TableStyle.ShowHeaders`, `AlwaysShowHeaders`) +- Column sizing, alignment, and formatting via `ColumnStyle` +- Only renders visible rows — efficient for large/streaming datasets +- `ITableSource` interface enables dynamic data (rows can grow while displayed) +- Multi-cell and full-row selection (`FullRowSelect`, `MultiSelect`) +- Checkbox support via `CheckBoxTableSourceWrapperByIndex` + +## Architecture + +### New Files + +| File | Class | Purpose | +|------|-------|---------| +| `OutConsoleTableViewCmdletCommand.cs` | `OutConsoleTableViewCmdletCommand` | PSCmdlet — streams objects in `ProcessRecord` | +| `OutTableViewWindow.cs` | `OutTableViewWindow : Runnable>` | Main UI window using `TableView` | +| `OutTableViewDataSource.cs` | `OutTableViewDataSource : ITableSource` | Thread-safe, dynamically-growing table source | + +### Reused Files (no changes needed) + +| File | Why | +|------|-----| +| `TypeGetter.cs` | PSObject-to-DataTable conversion (columns, property resolution) | +| `ApplicationData.cs` | Config container (add streaming flag if needed) | +| `DataTable.cs`, `DataTableRow.cs`, `DataTableColumn.cs` | Data model | +| `OutputModeOptions.cs` | `None` / `Single` / `Multiple` enum | +| `GridViewHelpers.cs` | `FilterData` regex logic (adapt for TableView rows) | +| `OutConsoleGridView.cs` | Orchestrator pattern (create a parallel `OutConsoleTableView.cs`) | + +### Files NOT needed (replaced by TableView) + +| File | Replaced by | +|------|-------------| +| `GridViewDataSource.cs` (IListDataSource) | `OutTableViewDataSource` (ITableSource) | +| `Header.cs` (custom header view) | `TableView`'s built-in headers | +| `GridViewRow.cs` (DisplayString formatting) | `ColumnStyle.RepresentationGetter` / `Format` | + +--- + +## Detailed Design + +### Phase 1: Cmdlet with Streaming Pipeline Support (Issue #209) + +#### 1.1 — `OutConsoleTableViewCmdletCommand` + +``` +[Cmdlet(VerbsData.Out, "ConsoleTableView")] +[Alias("octv")] +``` + +**Parameters** — identical to OCGV: +- `InputObject` (ValueFromPipeline) +- `Title`, `OutputMode`, `Filter`, `MinUI`, `ForceDriver`, `AllProperties` + +**Key difference from OCGV:** Move data processing from `EndProcessing` into `ProcessRecord`: + +``` +BeginProcessing(): + - Validate environment (same as OCGV) + - Initialize Terminal.Gui application on a background thread + - Create OutTableViewWindow (initially empty) + - Start the TG run loop on the background thread + +ProcessRecord(): + - Convert each PSObject via TypeGetter (determine columns on first object) + - Thread-safely add each DataTableRow to OutTableViewDataSource + - Signal the UI to refresh (Application.Invoke to marshal to UI thread) + +EndProcessing(): + - Signal "no more input" to the data source + - Wait for the UI to close (user presses Enter/Esc) + - Collect selected indexes and write selected PSObjects to pipeline +``` + +**Thread safety:** Terminal.Gui runs on its own thread. `ProcessRecord` runs on the pipeline thread. All UI mutations must go through `Application.Invoke()`. The `OutTableViewDataSource` needs a lock around its row list. + +#### 1.2 — `OutTableViewDataSource : ITableSource` + +A custom `ITableSource` implementation that supports dynamic row addition: + +```csharp +class OutTableViewDataSource : ITableSource +{ + // ITableSource + string[] ColumnNames { get; } + int Columns => ColumnNames.Length; + int Rows => _rows.Count; // grows over time + object this[int row, int col] => _rows[row].Values[col]; + + // Dynamic data + private readonly List _rows; // guarded by lock + private readonly ReaderWriterLockSlim _lock; + + void AddRow(DataTableRow row); // called from pipeline thread + void SetColumns(DataTableColumn[] columns); // called once on first object +} +``` + +**Column discovery:** On the first PSObject, call `TypeGetter` to determine columns. If a later object has *new* properties (heterogeneous pipeline), either: +- (Simple) Ignore new columns — match OCGV behavior +- (Future) Add columns dynamically and backfill nulls + +#### 1.3 — `OutTableViewWindow : Runnable>` + +UI layout: + +``` +┌─ Out-ConsoleTableView ─────────────────────────────┐ +│ Filter: [__________________] [x] All Properties │ ← TextField (regex) +│ │ +│ Name Status Id StartTime │ ← TableView built-in header +│ ──────────────────────────────────────────────────── │ +│ □ svchost Running 1234 2024-01-01 12:00 │ ← CheckBox column (if OutputMode != None) +│ □ explorer Running 5678 2024-01-01 12:01 │ +│ ... │ +│ │ +│ [Streaming: 1,234 rows received] │ ← Status indicator +├──────────────────────────────────────────────────────┤ +│ Space:Mark ^A:All ^D:None Enter:Accept Esc:Close │ ← StatusBar +└──────────────────────────────────────────────────────┘ +``` + +**Key TableView configuration:** +```csharp +var tableView = new TableView(dataSource) +{ + FullRowSelect = true, + MultiSelect = (outputMode == OutputModeOption.Multiple), + Style = new TableStyle + { + ShowHeaders = true, + AlwaysShowHeaders = true, + ExpandLastColumn = true, + ShowHorizontalHeaderUnderline = true, + } +}; +``` + +**Selection/marking via CheckBox wrapper:** +- For `OutputMode.Single` or `Multiple`: wrap `OutTableViewDataSource` in `CheckBoxTableSourceWrapperByIndex` +- This adds a checkbox column automatically +- Use `CellToggled` event to track selections +- `Ctrl+A` / `Ctrl+D` mapped to select-all / select-none + +**Filtering:** +- On filter text change, create a new filtered `OutTableViewDataSource` containing only matching rows +- Set `tableView.Table = filteredSource` +- Preserve check state across filter changes (track by `OriginalObjectIndex`) + +**Streaming status:** +- While pipeline is still sending data, show row count in StatusBar +- On `EndProcessing` signal, update status to final count + +### Phase 2: Polish & Parity + +#### 2.1 — MinUI Mode +- Remove frame, filter TextField, and StatusBar (same as OCGV) +- TableView only + +#### 2.2 — AllProperties Toggle +- Checkbox in filter bar (Alt+A) +- On toggle: re-run `TypeGetter.CastObjectsToTableView` with `allProperties: true` +- Rebuild columns and data source + +#### 2.3 — Keyboard Bindings +Match OCGV behavior: +| Key | Action | +|-----|--------| +| Space | Toggle mark on current row | +| Enter | Accept selection, close | +| Esc | Cancel, close (return nothing) | +| Ctrl+A | Mark all (Multiple mode) | +| Ctrl+D | Unmark all | +| Alt+A | Toggle AllProperties | +| Up/Down/PgUp/PgDn/Home/End | Navigation (TableView built-in) | + +#### 2.4 — Column Width Calculation +- Use `ColumnStyle.MinWidth` / `MaxWidth` per column +- Calculate natural widths from first N rows (not all, for streaming perf) +- Recalculate periodically as new rows arrive (or on user request) + +--- + +## Implementation Order + +### Step 1: Skeleton cmdlet + static TableView (no streaming) +1. Create `OutConsoleTableViewCmdletCommand.cs` — copy OCGV cmdlet, change names +2. Create `OutTableViewDataSource.cs` — implement `ITableSource` wrapping `DataTable` +3. Create `OutTableViewWindow.cs` — TableView with filter, status bar, selection +4. Create `OutConsoleTableView.cs` — orchestrator (copy from `OutConsoleGridView.cs`) +5. Register cmdlet in module manifest +6. **Goal:** `Get-Process | Out-ConsoleTableView` works identically to OCGV + +### Step 2: Selection & marking +1. Integrate `CheckBoxTableSourceWrapperByIndex` for mark support +2. Implement `Accept()` / `Close()` returning selected indexes +3. Wire up Ctrl+A, Ctrl+D, Space, Enter, Esc +4. **Goal:** `Get-Process | Out-ConsoleTableView -OutputMode Multiple` returns selected objects + +### Step 3: Filtering +1. Add regex filter TextField +2. On filter change, rebuild a filtered ITableSource +3. Preserve marks across filter changes +4. **Goal:** Filtering works as in OCGV + +### Step 4: Streaming pipeline (Issue #209) +1. Refactor cmdlet to start UI in `BeginProcessing` on background thread +2. Move object processing to `ProcessRecord` +3. Add thread-safe `AddRow` to `OutTableViewDataSource` +4. Marshal UI updates via `Application.Invoke` +5. Add streaming row count indicator +6. **Goal:** `1..100 | % { Start-Sleep -Milliseconds 100; $_ } | Out-ConsoleTableView` shows rows as they arrive + +### Step 5: Polish +1. MinUI mode +2. AllProperties toggle +3. Column width auto-sizing with periodic recalculation +4. ForceDriver support +5. Error handling parity with OCGV + +### Step 6: Tests +1. Port existing OCGV tests to OCTV equivalents +2. Add streaming-specific tests (rows appearing incrementally) +3. Add thread-safety tests for concurrent AddRow + filter + +--- + +## Key Design Decisions + +### 1. New cmdlet vs. replacing OCGV +**Decision:** New cmdlet (`Out-ConsoleTableView` / `octv`). Rationale: +- No breaking changes to existing OCGV users +- Can ship alongside OCGV for comparison +- Eventually OCGV could become an alias for OCTV once stable + +### 2. Streaming architecture +**Decision:** Background thread for Terminal.Gui, pipeline thread calls `AddRow`. +- Terminal.Gui must own a thread (it blocks on `Run()`) +- PowerShell pipeline must not block in `ProcessRecord` +- `Application.Invoke()` is the official TG mechanism for cross-thread UI updates + +### 3. Column discovery timing +**Decision:** Determine columns from the first PSObject, lock schema after that. +- Same as OCGV — uses `TypeGetter` which examines format definitions +- Heterogeneous pipelines: later objects with missing properties show empty cells +- Later objects with *extra* properties: those properties are ignored (OCGV parity) + +### 4. Checkbox vs. highlight selection +**Decision:** Use `CheckBoxTableSourceWrapperByIndex` for explicit mark indicators. +- Clearer UX than highlight-only selection +- Users can see marks persist across scrolling and filtering +- Matches OCGV's `IsMarked` visual pattern + +--- + +## Risk & Open Questions + +1. **Thread safety of Terminal.Gui's `Table` property setter** — Need to verify that changing `tableView.Table` from `Application.Invoke` is safe while the view is rendering. Likely fine since `Invoke` runs on the UI thread. + +2. **Column width instability during streaming** — As new rows arrive with wider values, columns may need to resize. Options: (a) fix widths after first batch, (b) allow periodic resize, (c) user-triggered resize. Start with (a). + +3. **Memory for very large streams** — All rows are kept in memory (same as OCGV). For truly infinite streams, may need a circular buffer or virtualized source in the future. Out of scope for v1. + +4. **`TypeGetter` thread safety** — `TypeGetter.CastObjectsToTableView` uses PowerShell runspace internals. Need to ensure it's called from the pipeline thread only, not from the UI thread. + +5. **Module manifest** — Need to add the new cmdlet to the exported commands list. diff --git a/global.json b/global.json index 19e4d7e..a1ce647 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.405", + "version": "10.0.101", "rollForward": "latestFeature", "allowPrerelease": false } diff --git a/nuget.config b/nuget.config index f003b0f..663bd86 100644 --- a/nuget.config +++ b/nuget.config @@ -3,5 +3,15 @@ + + + + + + + + + + diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/CachedMemberResult.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/CachedMemberResult.cs new file mode 100644 index 0000000..14534c5 --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/CachedMemberResult.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Represents a cached reflection result for a property or field member, including its value and collection details. +/// +internal sealed class CachedMemberResult +{ + #region Fields + + private readonly string? _representation; + private List? _valueAsList; + + #endregion + + #region Properties + + /// + /// Gets or sets the member information (property or field) that was accessed. + /// + public MemberInfo Member { get; set; } + + /// + /// Gets or sets the value retrieved from the member. + /// + public object? Value { get; set; } + + /// + /// Gets or sets the parent object that contains this member. + /// + public object Parent { get; set; } + + /// + /// Gets a value indicating whether this member's value is a collection. + /// + public bool IsCollection => _valueAsList != null; + + /// + /// Gets the collection elements if this member's value is a collection; otherwise, . + /// + public IReadOnlyCollection? Elements => _valueAsList?.AsReadOnly(); + + #endregion + + #region Constructor + + /// + /// Initializes a new instance of the class by reflecting on the specified member. + /// + /// The parent object containing the member. + /// The member information to retrieve the value from. + public CachedMemberResult(object parent, MemberInfo mem) + { + Parent = parent; + Member = mem; + + try + { + if (mem is PropertyInfo p) + Value = p.GetValue(parent); + else if (mem is FieldInfo f) + Value = f.GetValue(parent); + else + throw new NotSupportedException($"Unknown {nameof(MemberInfo)} Type"); + + _representation = ValueToString(); + } + catch (Exception) + { + Value = _representation = "Unavailable"; + } + } + + #endregion + + #region Overrides + + /// + /// Returns a string representation of this member in the format "MemberName: value". + /// + /// A formatted string showing the member name and value. + public override string ToString() => Member.Name + ": " + _representation; + + #endregion + + #region Private Methods + + /// + /// Converts the member's value to a string representation, detecting collections and formatting them appropriately. + /// + /// A string representation of the value. + private string? ValueToString() + { + if (Value == null) + return "Null"; + + try + { + if (IsCollectionOfKnownTypeAndSize(out var elementType, out var size)) + return $"{elementType!.Name}[{size}]"; + } + catch (Exception) + { + return Value?.ToString(); + } + + return Value?.ToString(); + } + + /// + /// Determines whether the value is a collection of a known type and caches the collection elements. + /// + /// When this method returns, contains the element type if the value is a homogeneous collection; otherwise, . + /// When this method returns, contains the size of the collection if applicable; otherwise, 0. + /// if the value is a collection of a single known type; otherwise, . + private bool IsCollectionOfKnownTypeAndSize(out Type? elementType, out int size) + { + elementType = null; + size = 0; + + if (Value is null or string) + return false; + + if (Value is IEnumerable enumerable) + { + var list = enumerable.Cast().ToList(); + + var types = list.Where(v => v != null).Select(v => v!.GetType()).Distinct().ToArray(); + + if (types.Length == 1) + { + elementType = types[0]; + size = list.Count; + + _valueAsList = list.Select((e, i) => new CachedMemberResultElement(e, i)).ToList(); + return true; + } + } + + return false; + } + + #endregion +} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/CachedMemberResultElement.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/CachedMemberResultElement.cs new file mode 100644 index 0000000..7efa001 --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/CachedMemberResultElement.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Represents an element within a collection member result, providing indexed access to collection items. +/// +internal sealed class CachedMemberResultElement +{ + #region Fields + + /// + /// The index of this element within the collection. + /// + public int Index; + + /// + /// The value of this collection element. + /// + public object? Value; + + private readonly string _representation; + + #endregion + + #region Constructor + + /// + /// Initializes a new instance of the class with the specified value and index. + /// + /// The value of the collection element. + /// The zero-based index of this element within the collection. + public CachedMemberResultElement(object? value, int index) + { + Index = index; + Value = value; + + try + { + _representation = Value?.ToString() ?? "Null"; + } + catch (Exception) + { + Value = _representation = "Unavailable"; + } + } + + #endregion + + #region Overrides + + /// + /// Returns a string representation of this collection element in the format "[index]: value". + /// + /// A formatted string showing the index and value. + public override string ToString() => $"[{Index}]: {_representation}]"; + + #endregion +} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs deleted file mode 100644 index e4385e1..0000000 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ConsoleGui.cs +++ /dev/null @@ -1,443 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Reflection; -using System.Text; - -using OutGridView.Models; - -using Terminal.Gui; - -namespace OutGridView.Cmdlet -{ - internal sealed class ConsoleGui : IDisposable - { - private const string FILTER_LABEL = "Filter"; - // This adjusts the left margin of all controls - private const int MARGIN_LEFT = 1; - // Width of Terminal.Gui ListView selection/check UI elements (old == 4, new == 2) - private const int CHECK_WIDTH = 2; - private bool _cancelled; - private Label _filterLabel; - private TextField _filterField; - private ListView _listView; - // _inputSource contains the full set of Input data and tracks any items the user - // marks. When the cmdlet exits, any marked items are returned. When a filter is - // active, the list view shows a copy of _inputSource that includes both the items - // matching the filter AND any items previously marked. - private GridViewDataSource _inputSource; - - // _listViewSource is a filtered copy of _inputSource that ListView.Source is set to. - // Changes to IsMarked are propogated back to _inputSource. - private GridViewDataSource _listViewSource; - private ApplicationData _applicationData; - private GridViewDetails _gridViewDetails; - - public HashSet Start(ApplicationData applicationData) - { - _applicationData = applicationData; - // Note, in Terminal.Gui v2, this property is renamed to Application.UseNetDriver, hence - // using that terminology here. - Application.UseSystemConsole = _applicationData.UseNetDriver; - Application.Init(); - _gridViewDetails = new GridViewDetails - { - // If OutputMode is Single or Multiple, then we make items selectable. If we make them selectable, - // 2 columns are required for the check/selection indicator and space. - ListViewOffset = _applicationData.OutputMode != OutputModeOption.None ? MARGIN_LEFT + CHECK_WIDTH : MARGIN_LEFT - }; - - Window win = CreateTopLevelWindow(); - - // Create the headers and calculate column widths based on the DataTable - List gridHeaders = _applicationData.DataTable.DataColumns.Select((c) => c.Label).ToList(); - CalculateColumnWidths(gridHeaders); - - // Copy the input DataTable into our master ListView source list; upon exit any items - // that are IsMarked are returned (if Outputmode is set) - _inputSource = LoadData(); - - if (!_applicationData.MinUI) - { - // Add Filter UI - AddFilter(win); - // Add Header UI - AddHeaders(win, gridHeaders); - } - - // Add ListView - AddListView(win); - - // Status bar is where our key-bindings are handled - AddStatusBar(!_applicationData.MinUI); - - // We *always* apply a filter, even if the -Filter parameter is not set or Filtering is not - // available. The ListView always shows a fitlered version of _inputSource even if there is no - // actual fitler. - ApplyFilter(); - - _listView.SetFocus(); - - // Run the GUI. - Application.Run(); - Application.Shutdown(); - - // Return results of selection if required. - HashSet selectedIndexes = new HashSet(); - if (_cancelled) - { - return selectedIndexes; - } - - // Return any items that were selected. - foreach (GridViewRow gvr in _inputSource.GridViewRowList) - { - if (gvr.IsMarked) - { - selectedIndexes.Add(gvr.OriginalIndex); - } - } - - return selectedIndexes; - } - - private GridViewDataSource LoadData() - { - var items = new List(); - int newIndex = 0; - for (int i = 0; i < _applicationData.DataTable.Data.Count; i++) - { - var dataTableRow = _applicationData.DataTable.Data[i]; - var valueList = new List(); - foreach (var dataTableColumn in _applicationData.DataTable.DataColumns) - { - string dataValue = dataTableRow.Values[dataTableColumn.ToString()].DisplayValue; - valueList.Add(dataValue); - } - - string displayString = GridViewHelpers.GetPaddedString(valueList, 0, _gridViewDetails.ListViewColumnWidths); - - items.Add(new GridViewRow - { - DisplayString = displayString, - // We use this to keep _inputSource up to date when a filter is applied - OriginalIndex = i - }); - - newIndex++; - } - - return new GridViewDataSource(items); - } - - private void ApplyFilter() - { - // The ListView is always filled with a (filtered) copy of _inputSource. - // We listen for `MarkChanged` events on this filtered list and apply those changes up to _inputSource. - - if (_listViewSource != null) - { - _listViewSource.MarkChanged -= ListViewSource_MarkChanged; - _listViewSource = null; - } - - _listViewSource = new GridViewDataSource(GridViewHelpers.FilterData(_inputSource.GridViewRowList, _applicationData.Filter ?? string.Empty)); - _listViewSource.MarkChanged += ListViewSource_MarkChanged; - _listView.Source = _listViewSource; - } - - private void ListViewSource_MarkChanged(object s, GridViewDataSource.RowMarkedEventArgs a) - { - _inputSource.GridViewRowList[a.Row.OriginalIndex].IsMarked = a.Row.IsMarked; - } - - private static void Accept() - { - Application.RequestStop(); - } - - private void Close() - { - _cancelled = true; - Application.RequestStop(); - } - - private Window CreateTopLevelWindow() - { - // Creates the top-level window to show - var win = new Window(_applicationData.Title) - { - X = _applicationData.MinUI ? -1 : 0, - Y = _applicationData.MinUI ? -1 : 0, - - // By using Dim.Fill(), it will automatically resize without manual intervention - Width = Dim.Fill(_applicationData.MinUI ? -1 : 0), - Height = Dim.Fill(_applicationData.MinUI ? -1 : 1) - }; - - if (_applicationData.MinUI) - { - win.Border.BorderStyle = BorderStyle.None; - } - - Application.Top.Add(win); - return win; - } - - private void AddStatusBar(bool visible) - { - var statusItems = new List(); - if (_applicationData.OutputMode != OutputModeOption.None) - { - // Use Key.Unknown for SPACE with no delegate because ListView already - // handles SPACE - statusItems.Add(new StatusItem(Key.Unknown, "~SPACE~ Select Item", null)); - } - - if (_applicationData.OutputMode == OutputModeOption.Multiple) - { - statusItems.Add(new StatusItem(Key.A | Key.CtrlMask, "~CTRL-A~ Select All", () => - { - // This selects only the items that match the Filter - var gvds = _listView.Source as GridViewDataSource; - gvds.GridViewRowList.ForEach(i => i.IsMarked = true); - _listView.SetNeedsDisplay(); - })); - - // Ctrl-D is commonly used in GUIs for select-none - statusItems.Add(new StatusItem(Key.D | Key.CtrlMask, "~CTRL-D~ Select None", () => - { - // This un-selects only the items that match the Filter - var gvds = _listView.Source as GridViewDataSource; - gvds.GridViewRowList.ForEach(i => i.IsMarked = false); - _listView.SetNeedsDisplay(); - })); - } - - if (_applicationData.OutputMode != OutputModeOption.None) - { - statusItems.Add(new StatusItem(Key.Enter, "~ENTER~ Accept", () => - { - if (Application.Top.MostFocused == _listView) - { - // If nothing was explicitly marked, we return the item that was selected - // when ENTER is pressed in Single mode. If something was previously selected - // (using SPACE) then honor that as the single item to return - if (_applicationData.OutputMode == OutputModeOption.Single && - _inputSource.GridViewRowList.Find(i => i.IsMarked) == null) - { - _listView.MarkUnmarkRow(); - } - Accept(); - } - else if (Application.Top.MostFocused == _filterField) - { - _listView.SetFocus(); - } - })); - } - - statusItems.Add(new StatusItem(Key.Esc, "~ESC~ Close", () => Close())); - if (_applicationData.Verbose || _applicationData.Debug) - { - statusItems.Add(new StatusItem(Key.Null, $" v{_applicationData.ModuleVersion}", null)); - statusItems.Add(new StatusItem(Key.Null, - $"{Application.Driver} v{FileVersionInfo.GetVersionInfo(Assembly.GetAssembly(typeof(Application)).Location).ProductVersion}", null)); - } - - var statusBar = new StatusBar(statusItems.ToArray()); - statusBar.Visible = visible; - Application.Top.Add(statusBar); - } - - private void CalculateColumnWidths(List gridHeaders) - { - _gridViewDetails.ListViewColumnWidths = new int[gridHeaders.Count]; - var listViewColumnWidths = _gridViewDetails.ListViewColumnWidths; - - for (int i = 0; i < gridHeaders.Count; i++) - { - listViewColumnWidths[i] = gridHeaders[i].Length; - } - - // calculate the width of each column based on longest string in each column for each row - foreach (var row in _applicationData.DataTable.Data) - { - int index = 0; - - // use half of the visible buffer height for the number of objects to inspect to calculate widths - foreach (var col in row.Values.Take(Application.Top.Frame.Height / 2)) - { - var len = col.Value.DisplayValue.Length; - if (len > listViewColumnWidths[index]) - { - listViewColumnWidths[index] = len; - } - index++; - } - } - - // if the total width is wider than the usable width, remove 1 from widest column until it fits - _gridViewDetails.UsableWidth = Application.Top.Frame.Width - MARGIN_LEFT - listViewColumnWidths.Length - _gridViewDetails.ListViewOffset; - int columnWidthsSum = listViewColumnWidths.Sum(); - while (columnWidthsSum >= _gridViewDetails.UsableWidth) - { - int maxWidth = 0; - int maxIndex = 0; - for (int i = 0; i < listViewColumnWidths.Length; i++) - { - if (listViewColumnWidths[i] > maxWidth) - { - maxWidth = listViewColumnWidths[i]; - maxIndex = i; - } - } - - listViewColumnWidths[maxIndex]--; - columnWidthsSum--; - } - } - - private void AddFilter(Window win) - { - _filterLabel = new Label(FILTER_LABEL) - { - X = MARGIN_LEFT, - Y = 0 - }; - - _filterField = new TextField(_applicationData.Filter ?? string.Empty) - { - X = Pos.Right(_filterLabel) + 1, - Y = Pos.Top(_filterLabel), - CanFocus = true, - Width = Dim.Fill() - 1 - }; - - // TextField captures Ctrl-A (select all text) and Ctrl-D (delete backwards) - // In OCGV these are used for select-all/none of items. Selecting items is more - // common than editing the filter field so we turn them off in the filter textview. - // BACKSPACE still works for delete backwards - _filterField.ClearKeybinding(Key.A | Key.CtrlMask); - _filterField.ClearKeybinding(Key.D | Key.CtrlMask); - - var filterErrorLabel = new Label(string.Empty) - { - X = Pos.Right(_filterLabel) + 1, - Y = Pos.Top(_filterLabel) + 1, - ColorScheme = Colors.Base, - Width = Dim.Fill() - _filterLabel.Text.Length - }; - - _filterField.TextChanged += (str) => - { - // str is the OLD value - string filterText = _filterField.Text?.ToString(); - try - { - filterErrorLabel.Text = " "; - filterErrorLabel.ColorScheme = Colors.Base; - filterErrorLabel.Redraw(filterErrorLabel.Bounds); - _applicationData.Filter = filterText; - ApplyFilter(); - - } - catch (Exception ex) - { - filterErrorLabel.Text = ex.Message; - filterErrorLabel.ColorScheme = Colors.Error; - filterErrorLabel.Redraw(filterErrorLabel.Bounds); - } - }; - - win.Add(_filterLabel, _filterField, filterErrorLabel); - - _filterField.Text = _applicationData.Filter ?? string.Empty; - _filterField.CursorPosition = _filterField.Text.Length; - } - - private void AddHeaders(Window win, List gridHeaders) - { - var header = new Label(GridViewHelpers.GetPaddedString( - gridHeaders, - _gridViewDetails.ListViewOffset, - _gridViewDetails.ListViewColumnWidths)); - header.X = 0; - if (_applicationData.MinUI) - { - header.Y = 0; - } - else - { - header.Y = 2; - } - win.Add(header); - - // This renders dashes under the header to make it more clear what is header and what is data - var headerLineText = new StringBuilder(); - foreach (char c in header.Text) - { - if (c.Equals(' ')) - { - headerLineText.Append(' '); - } - else - { - // When gui.cs supports text decorations, should replace this with just underlining the header - headerLineText.Append('-'); - } - } - - if (!_applicationData.MinUI) - { - var headerLine = new Label(headerLineText.ToString()) - { - X = 0, - Y = Pos.Bottom(header) - }; - win.Add(headerLine); - } - } - - private void AddListView(Window win) - { - _listView = new ListView(_inputSource); - _listView.X = MARGIN_LEFT; - if (!_applicationData.MinUI) - { - _listView.Y = Pos.Bottom(_filterLabel) + 3; // 1 for space, 1 for header, 1 for header underline - } - else - { - _listView.Y = 1; // 1 for space, 1 for header, 1 for header underline - } - _listView.Width = Dim.Fill(1); - _listView.Height = Dim.Fill(); - _listView.AllowsMarking = _applicationData.OutputMode != OutputModeOption.None; - _listView.AllowsMultipleSelection = _applicationData.OutputMode == OutputModeOption.Multiple; - _listView.AddKeyBinding(Key.Space, Command.ToggleChecked, Command.LineDown); - - win.Add(_listView); - } - - public void Dispose() - { - if (!Console.IsInputRedirected) - { - // By emitting this, we fix two issues: - // 1. An issue where arrow keys don't work in the console because .NET - // requires application mode to support Arrow key escape sequences. - // Esc[?1h sets the cursor key to application mode - // See http://ascii-table.com/ansi-escape-sequences-vt-100.php - // 2. An issue where moving the mouse causes characters to show up because - // mouse tracking is still on. Esc[?1003l turns it off. - // See https://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking - Console.Write("\u001b[?1h\u001b[?1003l"); - } - } - } -} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs index 4d6724a..7a6d17a 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDataSource.cs @@ -4,80 +4,124 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using Terminal.Gui.App; +using Terminal.Gui.Views; -using NStack; +namespace Microsoft.PowerShell.ConsoleGuiTools; -using Terminal.Gui; - -namespace OutGridView.Cmdlet +/// +/// Provides a data source implementation for the grid view that manages rows and supports marking and rendering. +/// +internal sealed class GridViewDataSource : IListDataSource { - internal sealed class GridViewDataSource : IListDataSource + /// + /// Gets or sets the list of rows displayed in the grid view. + /// + public List GridViewRowList { get; set; } + + /// + public int Count => GridViewRowList.Count; + + /// + public int MaxItemLength { get; set; } + + /// + public bool SuspendCollectionChangedEvent { get; set; } + +#pragma warning disable CS0067 + /// + public event NotifyCollectionChangedEventHandler? CollectionChanged; +#pragma warning restore CS0067 + + /// + /// Initializes a new instance of the class with the specified item list. + /// + /// The list of grid view rows to display. + public GridViewDataSource(List itemList) { - public List GridViewRowList { get; set; } + GridViewRowList = itemList; + } - public int Count => GridViewRowList.Count; + /// + public void Render(ListView listView, bool selected, int item, int col, int line, int width, int viewportX) + { + GridViewRow? row = GridViewRowList[item]; + string displayString = row?.DisplayString ?? string.Empty; - public GridViewDataSource(List itemList) + displayString = viewportX switch { - GridViewRowList = itemList; - } - - public int Length { get; } - - public void Render(ListView container, ConsoleDriver driver, bool selected, int item, int col, int line, int width, int start) + // Truncate the start of the string to skip characters scrolled out of view + > 0 when displayString.Length > viewportX => displayString[viewportX..], + > 0 when displayString.Length <= viewportX => string.Empty, + _ => displayString + }; + + // Pad right of display string with spaces to fill width, or truncate if too long + if (displayString.Length < width) { - container.Move(col, line); - RenderUstr(driver, GridViewRowList[item].DisplayString, col, line, width); + displayString = displayString.PadRight(width); } - - public bool IsMarked(int item) => GridViewRowList[item].IsMarked; - - public void SetMark(int item, bool value) + else if (displayString.Length > width) { - var oldValue = GridViewRowList[item].IsMarked; - GridViewRowList[item].IsMarked = value; - var args = new RowMarkedEventArgs() - { - Row = GridViewRowList[item], - OldValue = oldValue - }; - MarkChanged?.Invoke(this, args); + displayString = displayString[..width]; } - public sealed class RowMarkedEventArgs : EventArgs - { - public GridViewRow Row { get; set; } - public bool OldValue { get; set; } - - } + listView.AddStr(displayString); + } - public event EventHandler MarkChanged; + /// + public bool IsMarked(int item) + { + return item < GridViewRowList.Count && GridViewRowList[item].IsMarked; + } - public IList ToList() + /// + public void SetMark(int item, bool value) + { + var oldValue = GridViewRowList[item].IsMarked; + GridViewRowList[item].IsMarked = value; + var args = new RowMarkedEventArgs { - return GridViewRowList; - } + Row = GridViewRowList[item], + OldValue = oldValue + }; + MarkChanged?.Invoke(this, args); + } - // 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++; - } - } + /// + /// 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; + + /// + /// 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 } } diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDetails.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDetails.cs deleted file mode 100644 index 7c660f9..0000000 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewDetails.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace OutGridView.Cmdlet -{ - internal sealed class GridViewDetails - { - // Contains 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; } - - // 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; } - } -} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs index 482fe1d..44ee449 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewHelpers.cs @@ -1,39 +1,54 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.RegularExpressions; -namespace OutGridView.Cmdlet +namespace Microsoft.PowerShell.ConsoleGuiTools { + /// + /// Provides helper methods for filtering and formatting data in the grid view. + /// internal sealed class GridViewHelpers { - // Add all items already selected plus any that match the filter - // The selected items should be at the top of the list, in their original order + /// + /// Filters a list of grid view rows based on a regular expression pattern. + /// Marked items are always included and appear first in the result, followed by unmarked items that match the filter. + /// + /// The list of rows to filter. + /// The regular expression pattern to match against the display string. If null or empty, the original list is returned. + /// A filtered list with marked items first, followed by matching unmarked items. public static List FilterData(List listToFilter, string filter) { - var filteredList = new List(); if (string.IsNullOrEmpty(filter)) { return listToFilter; } + var filteredList = new List(); filteredList.AddRange(listToFilter.Where(gvr => gvr.IsMarked)); - filteredList.AddRange(listToFilter.Where(gvr => !gvr.IsMarked && Regex.IsMatch(gvr.DisplayString, filter, RegexOptions.IgnoreCase))); + filteredList.AddRange(listToFilter.Where(gvr => !gvr.IsMarked && Regex.IsMatch(gvr.DisplayString!, filter, RegexOptions.IgnoreCase))); return filteredList; } - public static string GetPaddedString(List strings, int offset, int[] listViewColumnWidths) + public static string GetPaddedString(List? strings, int offset, int[]? listViewColumnWidths) { + if (listViewColumnWidths is null) + { + return string.Empty; + } + var builder = new StringBuilder(); if (offset > 0) { builder.Append(string.Empty.PadRight(offset)); } + if (strings == null) return builder.ToString(); for (int i = 0; i < strings.Count; i++) { if (i > 0) @@ -44,12 +59,23 @@ public static string GetPaddedString(List strings, int offset, int[] lis // Replace any newlines with encoded newline/linefeed (`n or `r) // Note we can't use Environment.Newline because we don't know that the - // Command honors that. + // command honors that. strings[i] = strings[i].Replace("\r", "`r"); strings[i] = strings[i].Replace("\n", "`n"); - // If the string won't fit in the column, append an ellipsis. - if (strings[i].Length > listViewColumnWidths[i]) + // If the string doesn't fit in the column, append an ellipsis. + // Guard against negative or very small column widths + if (listViewColumnWidths[i] <= 0) + { + // Skip columns with zero or negative width (but separator already added above) + } + else if (listViewColumnWidths[i] < 4) + { + // For very small columns (< 4), just truncate without ellipsis + var truncateLength = Math.Min(strings[i].Length, listViewColumnWidths[i]); + builder.Append(strings[i], 0, truncateLength); + } + else if (strings[i].Length > listViewColumnWidths[i]) { builder.Append(strings[i], 0, listViewColumnWidths[i] - 3); builder.Append("..."); @@ -63,4 +89,4 @@ public static string GetPaddedString(List strings, int offset, int[] lis return builder.ToString(); } } -} +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewRow.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewRow.cs index 2ecc80e..6db806c 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewRow.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/GridViewRow.cs @@ -1,13 +1,31 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace OutGridView.Cmdlet +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Represents a single row in the grid view, including its display text, marked state, and original position. +/// +public class GridViewRow { - public class GridViewRow - { - public string DisplayString { get; set; } - public bool IsMarked { get; set; } - public int OriginalIndex { get; set; } - public override string ToString() => DisplayString; - } -} + /// + /// Gets or sets the formatted string to display for this row in the grid view. + /// + public string? DisplayString { get; set; } + + /// + /// Gets or sets a value indicating whether this row is marked (selected) by the user. + /// + public bool IsMarked { get; set; } + + /// + /// Gets or sets the original index of this row in the source data before any filtering or sorting. + /// + public int OriginalIndex { get; set; } + + /// + /// Returns the display string representation of this row. + /// + /// The value. + public override string ToString() => DisplayString ?? string.Empty; +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Header.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/Header.cs new file mode 100644 index 0000000..4eeeeba --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Header.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +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. This is added to +/// the top of the Padding of the ListView. +/// +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 PaddingView padding) padding.Adornment?.Parent?.ViewportChanged += ListViewOnViewportChanged; + + return; + + 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 PaddingView { Adornment.Parent: ListView listView }) + SetContentSize(GetContentSize() with { Width = listView.GetContentSize().Width }); + + base.OnSubViewLayout(args); + } + + /// + /// Updates the header with new column strings and widths. + /// + /// The list of header strings to display. + /// The width of each column. + public void SetHeaders(List? headers, int[]? columnWidths) + { + if (headers == null || columnWidths == null) + return; + + // Clear existing labels + RemoveAll(); + + // Create a label for each header + var currentX = 0; + for (var i = 0; i < headers.Count; i++) + { + // Skip columns with zero width + if (columnWidths[i] <= 0) + continue; + + var column = new View + { + Text = headers[i], + X = currentX, + Y = 0, + Width = Dim.Auto(DimAutoStyle.Text), + Height = 1, + TextAlignment = Alignment.Start, + VerticalTextAlignment = Alignment.Start + }; + column.GettingAttributeForRole += ColumnOnGettingAttributeForRole; + + void ColumnOnGettingAttributeForRole(object? sender, VisualRoleEventArgs e) + { + if (e.Role == VisualRole.Normal) + { + e.Result = e.Result!.Value with { Style = TextStyle.Bold | TextStyle.Underline }; + e.Handled = true; + } + } + + Add(column); + + // Move to next column position (width + 1 space separator) + currentX += columnWidths[i] + 1; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj b/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj index ed25247..a0d7dd9 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj @@ -1,7 +1,9 @@ - + - net8.0 + net10.0 + preview + enable @@ -11,26 +13,33 @@ - Add ';https://api.nuget.org/v3/index.json' to the end of the RestoreSources property group below - Uncomment the RestoreSources property group below --> - + - - + - + + + + + true true Recommended + + + true + diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.psd1 b/src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.psd1 index 80c0fe8..cbe1538 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' ) @@ -69,13 +69,13 @@ NestedModules = @() FunctionsToExport = @() # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. -CmdletsToExport = @( 'Out-ConsoleGridView', 'Show-ObjectTree' ) +CmdletsToExport = @( 'Out-ConsoleGridView', 'Out-ConsoleTableView', 'Show-ObjectTree' ) # Variables to export from this module VariablesToExport = '*' # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. -AliasesToExport = @( 'ocgv', 'shot' ) +AliasesToExport = @( 'ocgv', 'octv', 'shot' ) # DSC resources to export from this module # DscResourcesToExport = @() diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridView.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridView.cs new file mode 100644 index 0000000..18b85a3 --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridView.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Configuration; +using Microsoft.PowerShell.OutGridView.Models; +using Terminal.Gui.App; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Provides the main orchestration for the Out-ConsoleGridView cmdlet, managing the Terminal.Gui application lifecycle +/// and coordinating between the application data and the grid view window. +/// +/// +/// This class serves as a facade that initializes the Terminal.Gui framework, creates and runs the grid view window, +/// and handles cleanup operations. It delegates the actual UI rendering and user interaction to the +/// class. +/// +internal sealed class OutConsoleGridView : IDisposable +{ + private 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; + + Terminal.Gui.Configuration.ConfigurationManager.Enable(Terminal.Gui.Configuration.ConfigLocations.All); + + using OutGridViewWindow window = new(_applicationData); + using IApplication app = Application.Create().Init(driverName: _applicationData.ForceDriver); + HashSet? selectedIndexes = app.Run(window) as HashSet; + return selectedIndexes ?? []; + } + + /// + /// Releases resources used by the . + /// + /// + /// Currently, there are no resources to dispose. This method is provided for future extensibility + /// and to follow the standard IDisposable pattern. + /// + public void Dispose() + { + // No resources to dispose currently + } +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs index 94bb7e1..6481dc2 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleGridviewCmdletCommand.cs @@ -4,184 +4,193 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using System.Management.Automation; using System.Management.Automation.Internal; +using Microsoft.PowerShell.OutGridView.Models; -using OutGridView.Models; +namespace Microsoft.PowerShell.ConsoleGuiTools; -namespace OutGridView.Cmdlet +/// +/// Sends output to an interactive table in a separate console window. This class is invoked by PowerShell when the +/// Out-ConsoleGridView cmdlet is called. +/// +[Cmdlet(VerbsData.Out, "ConsoleGridView")] +[Alias("ocgv")] +public class OutConsoleGridViewCmdletCommand : PSCmdlet, IDisposable { - [Cmdlet(VerbsData.Out, "ConsoleGridView")] - [Alias("ocgv")] - public class OutConsoleGridViewCmdletCommand : PSCmdlet, IDisposable + #region Properties + + private const string DATA_NOT_QUALIFIED_FOR_GRID_VIEW = nameof(DATA_NOT_QUALIFIED_FOR_GRID_VIEW); + private const string ENVIRONMENT_NOT_SUPPORTED_FOR_GRID_VIEW = nameof(ENVIRONMENT_NOT_SUPPORTED_FOR_GRID_VIEW); + + private readonly List _psObjects = []; + private readonly OutConsoleGridView _outConsoleGridView = new(); + + #endregion Properties + + #region Input Parameters + + /// + /// Gets or sets the current pipeline object. + /// + [Parameter(ValueFromPipeline = true, HelpMessage = "Specifies the input pipeline object")] + public PSObject InputObject { get; set; } = AutomationNull.Value; + + /// + /// Gets or sets the title of the Out-ConsoleGridView window. + /// + [Parameter(HelpMessage = + "Specifies the text that appears in the title bar of the Out-ConsoleGridView window. By default, the title bar displays the command that invokes Out-ConsoleGridView.")] + [ValidateNotNullOrEmpty] + public string? Title { get; set; } + + /// + /// Gets or sets a value indicating whether the selected items should be written to the pipeline + /// and if it should be possible to select multiple or single list items. + /// + [Parameter(HelpMessage = + "Determines whether a single item (Single), multiple items (Multiple; default), or no items (None) will be written to the pipeline. Also determines selection behavior in the TUI.")] + public OutputModeOption OutputMode { set; get; } = OutputModeOption.Multiple; + + /// + /// Gets or sets the initial value for the filter in the TUI. + /// + [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 TUI.")] + public SwitchParameter MinUI { set; get; } + + /// + /// Gets or sets the Terminal.Gui driver to use. + /// + [Parameter(HelpMessage = + "Forces the Terminal.Gui driver to use. Valid values are 'ansi', 'windows', or 'unix'.")] + public string? ForceDriver { set; get; } + + /// + /// Gets or sets a value indicating whether all properties should be displayed instead of just the default display properties. + /// + [Parameter(HelpMessage = + "If specified, all properties of the objects will be displayed instead of just the default display properties.")] + public SwitchParameter AllProperties { set; get; } + + /// + /// Gets a value indicating whether the Verbose switch is present. + /// + public bool Verbose => MyInvocation.BoundParameters.ContainsKey("Verbose"); + + /// + /// Gets a value indicating whether the Debug switch is present. + /// + public bool Debug => MyInvocation.BoundParameters.ContainsKey("Debug"); + + #endregion Input Parameters + + /// + /// Performs initialization of command execution. Validates that the environment supports grid view. + /// + protected override void BeginProcessing() { - #region Properties - - private const string DataNotQualifiedForGridView = nameof(DataNotQualifiedForGridView); - private const string EnvironmentNotSupportedForGridView = nameof(EnvironmentNotSupportedForGridView); - - private List _psObjects = new List(); - private ConsoleGui _consoleGui = new ConsoleGui(); - - #endregion Properties - - #region Input Parameters - - /// - /// This parameter specifies the current pipeline object. - /// - [Parameter(ValueFromPipeline = true, HelpMessage = "Specifies the input pipeline object")] - public PSObject InputObject { get; set; } = AutomationNull.Value; - - /// - /// Gets/sets the title of the Out-GridView window. - /// - [Parameter(HelpMessage = "Specifies the text that appears in the title bar of the Out-ConsoleGridView window. y default, the title bar displays the command that invokes Out-ConsoleGridView.")] - [ValidateNotNullOrEmpty] - public string Title { get; set; } - - /// - /// Get or sets a value indicating whether the selected items should be written to the pipeline - /// and if it should be possible to select multiple or single list items. - /// - [Parameter(HelpMessage = "Determines whether a single item (Single), multiple items (Multiple; default), or no items (None) will be written to the pipeline. Also determines selection behavior in the GUI.")] - public OutputModeOption OutputMode { set; get; } = OutputModeOption.Multiple; - - /// - /// gets or sets the initial value for the filter in the GUI - /// - [Parameter(HelpMessage = "Pre-populates the Filter edit box, allowing filtering to be specified on the command line. The filter uses regular expressions.")] - public string Filter { set; get; } - - /// - /// gets or sets the whether "minimum UI" mode will be enabled - /// - [Parameter(HelpMessage = "If specified no window frame, filter box, or status bar will be displayed in the GUI.")] - public SwitchParameter MinUI { set; get; } - - /// - /// gets or sets the whether the Terminal.Gui System.Net.Console-based ConsoleDriver will be used instead of the - /// default platform-specific (Windows or Curses) ConsoleDriver. - /// - [Parameter(HelpMessage = "If specified the Terminal.Gui System.Net.Console-based ConsoleDriver (NetDriver) will be used.")] - public SwitchParameter UseNetDriver { set; get; } - - /// - /// For the -Verbose switch - /// - public bool Verbose => MyInvocation.BoundParameters.TryGetValue("Verbose", out var o); - - /// - /// For the -Debug switch - /// - public bool Debug => MyInvocation.BoundParameters.TryGetValue("Debug", out var o); - - #endregion Input Parameters - - // This method gets called once for each cmdlet in the pipeline when the pipeline starts executing - protected override void BeginProcessing() + if (Console.IsInputRedirected) { - if (Console.IsInputRedirected) - { - ErrorRecord error = new ErrorRecord( - new PSNotSupportedException("Not supported in this environment (when input is redirected)."), - EnvironmentNotSupportedForGridView, - ErrorCategory.NotImplemented, - null); - - ThrowTerminatingError(error); - } - } + var error = new ErrorRecord( + new PSNotSupportedException("Not supported in this environment (when input is redirected)."), + ENVIRONMENT_NOT_SUPPORTED_FOR_GRID_VIEW, + ErrorCategory.NotImplemented, + null); - // This method will be called for each input received from the pipeline to this cmdlet; if no input is received, this method is not called - protected override void ProcessRecord() - { - if (InputObject == null || InputObject == AutomationNull.Value) - { - return; - } - - if (InputObject.BaseObject is IDictionary dictionary) - { - // Dictionaries should be enumerated through because the pipeline does not enumerate through them. - foreach (DictionaryEntry entry in dictionary) - { - ProcessObject(PSObject.AsPSObject(entry)); - } - } - else - { - ProcessObject(InputObject); - } + ThrowTerminatingError(error); } + } - private void ProcessObject(PSObject input) + /// + /// Processes each input object received from the pipeline. + /// + protected override void ProcessRecord() + { + if (Equals(InputObject, AutomationNull.Value)) return; + + if (InputObject.BaseObject is IDictionary dictionary) + // Dictionaries should be enumerated through because the pipeline does not enumerate through them. + foreach (DictionaryEntry entry in dictionary) + ProcessObject(PSObject.AsPSObject(entry)); + else + ProcessObject(InputObject); + } + + /// + /// Processes a single object for display in the grid view. + /// + /// The PSObject to process. + /// Thrown when the data type is not supported for Out-ConsoleGridView. + private void ProcessObject(PSObject input) + { + var baseObject = input.BaseObject; + + // Throw a terminating error for types that are not supported. + if (baseObject is ScriptBlock || + baseObject is SwitchParameter || + baseObject is PSReference || + baseObject is PSObject) { + var error = new ErrorRecord( + new FormatException("Invalid data type for Out-ConsoleGridView"), + DATA_NOT_QUALIFIED_FOR_GRID_VIEW, + ErrorCategory.InvalidType, + null); - object baseObject = input.BaseObject; + ThrowTerminatingError(error); + } - // Throw a terminating error for types that are not supported. - if (baseObject is ScriptBlock || - baseObject is SwitchParameter || - baseObject is PSReference || - baseObject is PSObject) - { - ErrorRecord error = new ErrorRecord( - new FormatException("Invalid data type for Out-ConsoleGridView"), - DataNotQualifiedForGridView, - ErrorCategory.InvalidType, - null); + _psObjects.Add(input); + } - ThrowTerminatingError(error); - } + /// + /// Performs final processing after all pipeline objects have been received. + /// Displays the console grid view and writes selected objects to the pipeline. + /// + protected override void EndProcessing() + { + base.EndProcessing(); - _psObjects.Add(input); - } + // Return if no objects + if (_psObjects.Count == 0) return; - // This method will be called once at the end of pipeline execution; if no input is received, this method is not called - protected override void EndProcessing() + var applicationData = new ApplicationData { - base.EndProcessing(); - - //Return if no objects - if (_psObjects.Count == 0) - { - return; - } - - var TG = new TypeGetter(this); - - var dataTable = TG.CastObjectsToTableView(_psObjects); - var applicationData = new ApplicationData - { - Title = Title ?? "Out-ConsoleGridView", - OutputMode = OutputMode, - Filter = Filter, - MinUI = MinUI, - DataTable = dataTable, - UseNetDriver = UseNetDriver, - Verbose = Verbose, - Debug = Debug, - ModuleVersion = MyInvocation.MyCommand.Version.ToString() - }; - - - var selectedIndexes = _consoleGui.Start(applicationData); - foreach (int idx in selectedIndexes) - { - var selectedObject = _psObjects[idx]; - if (selectedObject == null) - { - continue; - } - WriteObject(selectedObject, false); - } - } - - public void Dispose() + PSObjects = _psObjects.Cast().ToList(), + Title = Title ?? "Out-ConsoleGridView", + OutputMode = OutputMode, + Filter = Filter, + MinUI = MinUI, + ForceDriver = ForceDriver, + AllProperties = AllProperties, + Verbose = Verbose, + Debug = Debug, + ModuleVersion = MyInvocation.MyCommand.Version.ToString() + }; + + HashSet selectedIndexes = _outConsoleGridView.Run(applicationData); + foreach (var idx in selectedIndexes) { - _consoleGui.Dispose(); - GC.SuppressFinalize(this); + var selectedObject = _psObjects[idx]; + + WriteObject(selectedObject, false); } } -} + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + _outConsoleGridView.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleTableView.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleTableView.cs new file mode 100644 index 0000000..e202263 --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleTableView.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using Microsoft.PowerShell.OutGridView.Models; +using Terminal.Gui.App; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Provides the main orchestration for the non-streaming Out-ConsoleTableView path, +/// managing the Terminal.Gui application lifecycle. Used when all objects are available +/// upfront (e.g., called from the static orchestrator or tests). +/// +internal sealed class OutConsoleTableView : System.IDisposable +{ + /// + /// Runs the table view Terminal.Gui Application with the specified configuration + /// and returns the indexes of selected items. + /// + public HashSet Run(ApplicationData applicationData) + { + // Convert PSObjects to DataTable + DataTable dataTable; + if (applicationData.PSObjects is { Count: > 0 }) + { + var psObjects = applicationData.PSObjects.Cast().ToList(); + dataTable = TypeGetter.CastObjectsToTableView(psObjects, applicationData.AllProperties); + } + else + { + dataTable = new DataTable([], []); + } + + var dataSource = OutTableViewDataSource.FromDataTable(dataTable); + + Terminal.Gui.Configuration.ConfigurationManager.Enable(Terminal.Gui.Configuration.ConfigLocations.All); + + using OutTableViewWindow window = new(applicationData, dataSource); + window.OnPipelineComplete(); // All data is available upfront + using IApplication app = Application.Create().Init(driverName: applicationData.ForceDriver); + HashSet? selectedIndexes = app.Run(window) as HashSet; + return selectedIndexes ?? []; + } + + public void Dispose() + { + // No resources to dispose currently + } +} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleTableViewCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleTableViewCmdletCommand.cs new file mode 100644 index 0000000..56eb95b --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutConsoleTableViewCmdletCommand.cs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Sends output to an interactive table using Terminal.Gui's TableView. +/// This cmdlet uses the same pipeline model as Out-ConsoleGridView (collecting objects +/// in ProcessRecord, displaying in EndProcessing) but renders with TableView for +/// native column headers, sizing, and horizontal scrolling. +/// +[Cmdlet(VerbsData.Out, "ConsoleTableView")] +[Alias("octv")] +public class OutConsoleTableViewCmdletCommand : PSCmdlet, IDisposable +{ + #region Properties + + private const string DATA_NOT_QUALIFIED_FOR_TABLE_VIEW = nameof(DATA_NOT_QUALIFIED_FOR_TABLE_VIEW); + private const string ENVIRONMENT_NOT_SUPPORTED_FOR_TABLE_VIEW = nameof(ENVIRONMENT_NOT_SUPPORTED_FOR_TABLE_VIEW); + + private readonly List _psObjects = []; + private readonly OutConsoleTableView _outConsoleTableView = 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-ConsoleTableView window. + /// + [Parameter(HelpMessage = + "Specifies the text that appears in the title bar of the Out-ConsoleTableView window. By default, the title bar displays the command that invokes Out-ConsoleTableView.")] + [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 TUI.")] + public OutputModeOption OutputMode { set; get; } = OutputModeOption.Multiple; + + /// + /// Gets or sets the initial value for the filter in the TUI. + /// + [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 TUI.")] + public SwitchParameter MinUI { set; get; } + + /// + /// Gets or sets the Terminal.Gui driver to use. + /// + [Parameter(HelpMessage = + "Forces the Terminal.Gui driver to use. Valid values are 'ansi', 'windows', or 'unix'.")] + public string? ForceDriver { set; get; } + + /// + /// Gets or sets a value indicating whether all properties should be displayed. + /// + [Parameter(HelpMessage = + "If specified, all properties of the objects will be displayed instead of just the default display properties.")] + public SwitchParameter AllProperties { set; get; } + + /// + /// Gets a value indicating whether the Verbose switch is present. + /// + public bool Verbose => MyInvocation.BoundParameters.ContainsKey("Verbose"); + + /// + /// Gets a value indicating whether the Debug switch is present. + /// + public bool Debug => MyInvocation.BoundParameters.ContainsKey("Debug"); + + #endregion Input Parameters + + /// + /// Validates that the environment supports the table view. + /// + protected override void BeginProcessing() + { + if (Console.IsInputRedirected) + { + var error = new ErrorRecord( + new PSNotSupportedException("Not supported in this environment (when input is redirected)."), + ENVIRONMENT_NOT_SUPPORTED_FOR_TABLE_VIEW, + ErrorCategory.NotImplemented, + null); + + 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; + + // 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-ConsoleTableView"), + DATA_NOT_QUALIFIED_FOR_TABLE_VIEW, + ErrorCategory.InvalidType, + null); + + ThrowTerminatingError(error); + } + + _psObjects.Add(input); + } + + /// + /// Performs final processing after all pipeline objects have been received. + /// Displays the console table view and writes selected objects to the pipeline. + /// + protected override void EndProcessing() + { + base.EndProcessing(); + + // Return if no objects + if (_psObjects.Count == 0) return; + + var applicationData = new ApplicationData + { + PSObjects = _psObjects.Cast().ToList(), + Title = Title ?? "Out-ConsoleTableView", + OutputMode = OutputMode, + Filter = Filter, + MinUI = MinUI, + ForceDriver = ForceDriver, + AllProperties = AllProperties, + Verbose = Verbose, + Debug = Debug, + ModuleVersion = MyInvocation.MyCommand.Version.ToString() + }; + + HashSet selectedIndexes = _outConsoleTableView.Run(applicationData); + foreach (var idx in selectedIndexes) + { + var selectedObject = _psObjects[idx]; + + WriteObject(selectedObject, false); + } + } + + /// + /// Releases all resources. + /// + public void Dispose() + { + _outConsoleTableView.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs new file mode 100644 index 0000000..4af2bc9 --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutGridViewWindow.cs @@ -0,0 +1,491 @@ +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.Configuration; +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 : Runnable> +{ + private const string FILTER_LABEL = "_Filter:"; + private const int MARGIN_LEFT = 0; + private const int CHECK_WIDTH = 2; + private readonly ApplicationData _applicationData; + private DataTable? _dataTable; + private View? _filterErrorView; + private TextField? _filterField; + + private Label? _filterLabel; + private Header? _header; + private GridViewDataSource? _inputSource; + private ListView? _listView; + private GridViewDataSource? _filteredSource; + private int[]? _naturalColumnWidths; + private StatusBar? _statusBar; + + /// + /// Initializes a new instance of the class with the specified application data. + /// + /// The configuration and data to display in the grid view. + public OutGridViewWindow(ApplicationData applicationData) + { + _applicationData = applicationData; + Title = _applicationData.Title ?? "Out-ConsoleGridView"; + SchemeName = SchemeManager.SchemesToSchemeName(Schemes.Base); + BorderStyle = Window.DefaultBorderStyle; + + switch (_applicationData.MinUI) + { + case true: + BorderStyle = LineStyle.None; + break; + case false: + AddFilter(); + break; + } + + // Convert PSObjects to DataTable using TypeGetter which handles format data properly + if (_applicationData.PSObjects is { Count: > 0 }) + { + var psObjects = _applicationData.PSObjects.Cast().ToList(); + _dataTable = TypeGetter.CastObjectsToTableView(psObjects, _applicationData.AllProperties); + } + else + { + _dataTable = new DataTable([], []); + } + + // Copy the input DataTable into our master source list + _inputSource = LoadData(); + + AddListView(); + ApplyFilter(); + } + + protected override void OnIsRunningChanged(bool newIsRunning) + { + base.OnIsRunningChanged(newIsRunning); + if (!newIsRunning) return; + + // We do this here, because _statusBar requires the Application to be running to + // access the driver information. + AddStatusBar(); + _listView?.SetFocus(); + } + + /// + /// Gets the original indexes of all marked rows. + /// + /// A set of zero-based indexes from the original data table. + public HashSet GetSelectedIndexes() + { + if (_inputSource == null) return []; + + var selectedIndexes = new HashSet(); + + foreach (var gvr in _inputSource.GridViewRowList.Where(gvr => gvr.IsMarked)) + selectedIndexes.Add(gvr.OriginalIndex); + + return selectedIndexes; + } + + #region Data Management + + /// + /// Loads data from the application data table into grid view rows. + /// + /// A data source containing the loaded rows. + private GridViewDataSource LoadData() + { + var items = new List(); + if (_dataTable?.Data.Count == 0) + return new GridViewDataSource(items); + + // Calculate and cache natural column widths + _naturalColumnWidths = CalculateNaturalColumnWidths(_dataTable?.DataColumns.Select(c => c.Label).ToList()); + + for (var i = 0; i < _dataTable?.Data.Count; i++) + { + var dataTableRow = _dataTable.Data[i]; + var valueList = new List(); + foreach (var dataTableColumn in _dataTable.DataColumns) + { + var columnKey = dataTableColumn.ToString(); + + // Check if the key exists in the dictionary + valueList.Add(dataTableRow.Values.TryGetValue(columnKey, out var value) + ? value.DisplayValue + // Key not found - this means the dictionary was populated with different keys + // This is a bug - let's add empty string for now to avoid crash + : string.Empty); + } + + items.Add(new GridViewRow + { + DisplayString = GridViewHelpers.GetPaddedString(valueList, 0, _naturalColumnWidths), + OriginalIndex = i + }); + } + + // Anytime we load, we need to update the headers. + // Note, if ListView had a SourceChanged event, this could be done more automatically. + _header?.SetHeaders(_dataTable?.DataColumns.Select(c => c.Label).ToList(), _naturalColumnWidths); + + var source = new GridViewDataSource(items); + source.MaxItemLength = _naturalColumnWidths!.Sum() + (_naturalColumnWidths!.Length - 1); + return source; + } + + #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 (_filteredSource != null) + { + selectedItem = _filteredSource.GridViewRowList.ElementAtOrDefault(_listView?.SelectedItem ?? 0); + _filteredSource.MarkChanged -= OnFilteredSourceMarkChanged; + _filteredSource = null; + } + + // TODO: this is probably not needed; it is here defensively + _inputSource ??= LoadData(); + + try + { + _filteredSource = new GridViewDataSource(GridViewHelpers.FilterData(_inputSource.GridViewRowList, + _applicationData.Filter ?? string.Empty)); + _filteredSource.MaxItemLength = _inputSource.MaxItemLength; + } + catch (RegexParseException ex) + { + _filterErrorView?.Text = ex.Message; + } + + _filteredSource?.MarkChanged += OnFilteredSourceMarkChanged; + + _listView?.Source = _filteredSource; + + if (selectedItem is { } && _filteredSource != null) + { + var newIndex = + _filteredSource.GridViewRowList.FindIndex(i => i.OriginalIndex == selectedItem.OriginalIndex); + if (newIndex >= 0 && _listView != null) + _listView.SelectedItem = newIndex; + } + + if (_listView?.SelectedItem == null && _listView is { Source.Count: > 0 }) + _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 OnFilteredSourceMarkChanged(object? s, GridViewDataSource.RowMarkedEventArgs a) + { + _inputSource?.GridViewRowList[a.Row.OriginalIndex].IsMarked = a.Row.IsMarked; + } + + #endregion + + #region User Actions + + /// + /// Reloads the data with the specified AllProperties setting. + /// + private void ReloadDataWithAllProperties(bool allProperties) + { + _applicationData.AllProperties = allProperties; + + // Recreate the data table with the new property settings + DataTable newDataTable; + if (_applicationData.PSObjects is { Count: > 0 }) + { + var psObjects = _applicationData.PSObjects.Cast().ToList(); + newDataTable = TypeGetter.CastObjectsToTableView(psObjects, allProperties); + } + else + { + newDataTable = new DataTable([], []); + } + + _dataTable = newDataTable; + + // Reload and reapply filter + _inputSource = LoadData(); + ApplyFilter(); + + // Update status bar to show current state + UpdateStatusBar(); + + // Force redraw + SetNeedsLayout(); + SetNeedsDraw(); + } + + /// + /// Updates the status bar to reflect the current AllProperties state. + /// + private void UpdateStatusBar() + { + if (_statusBar == null) return; + + // Remove and recreate status bar to update the checkbox text + Remove(_statusBar); + AddStatusBar(); + } + + /// + /// Accepts the current selection and closes the window. + /// + private void Accept() + { + Result = GetSelectedIndexes(); + App?.RequestStop(); + } + + /// + /// Cancels the operation and closes the window. + /// + private void Close() + { + Result = null; + App?.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 = SchemeManager.SchemesToSchemeName(Schemes.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.InsertionPoint = _filterField.Text.Length; + } + + /// + /// Adds the main list view control to the window with configured selection behavior. + /// + private void AddListView() + { + _listView = new ListView + { + X = MARGIN_LEFT, + Y = _filterErrorView is { } ? Pos.Bottom(_filterErrorView) : 1, + Width = Dim.Fill(), + Height = Dim.Fill(1), + ShowMarks = _applicationData.OutputMode != OutputModeOption.None, + MarkMultiple = _applicationData.OutputMode == OutputModeOption.Multiple, + SelectedItem = 0, + ViewportSettings = ViewportSettingsFlags.HasScrollBars + }; + + _listView.KeyBindings.Remove(Key.A.WithCtrl); + + if (!_applicationData.MinUI) AddHeader(); + + _listView.Accepted += (sender, args) => Accept(); + + Add(_listView); + return; + + void AddHeader() + { + _header = new Header + { + X = CHECK_WIDTH + }; + + + _listView.Padding.Thickness = _listView.Padding.Thickness with { Top = 1 }; + _listView.Padding.GetOrCreateView().Add (_header); + _listView.VerticalScrollBar.Y = 1; + + _header?.SetHeaders(_dataTable?.DataColumns.Select(c => c.Label).ToList(), _naturalColumnWidths); + } + } + + + /// + /// Adds the status bar with keyboard shortcuts to the window. + /// + private void AddStatusBar() + { + var shortcuts = new List(); + if (_applicationData.OutputMode != OutputModeOption.None) + shortcuts.Add(new Shortcut(Key.Space, "Select", null)); + + if (_applicationData.OutputMode == OutputModeOption.Multiple) + { + shortcuts.Add(new Shortcut(Key.A.WithCtrl, "Sel. All", () => + { + _listView?.MarkAll(true); + _listView?.SetNeedsDraw(); + })); + + shortcuts.Add(new Shortcut(Key.D.WithCtrl, "Sel. None", () => + { + _listView?.MarkAll(false); + _listView?.SetNeedsDraw(); + })); + } + + if (_applicationData.OutputMode != OutputModeOption.None) + shortcuts.Add(new Shortcut(Key.Enter, "Accept", () => + { + if (MostFocused == _filterField) + { + _listView!.SetFocus(); + } + })); + + shortcuts.Add(new Shortcut(Key.Esc, "Close", Close)); + + var allPropertiesShortcut = new Shortcut + { + CommandView = new CheckBox + { + Title = "A_ll Properties", + Value = _applicationData.AllProperties ? CheckState.Checked : CheckState.UnChecked, + CanFocus = false, + MouseHighlightStates = MouseState.None + }, + CanFocus = false, + BindKeyToApplication = true + }; + + allPropertiesShortcut.Accepting += (_, e) => + { + ReloadDataWithAllProperties(!_applicationData.AllProperties); + e.Handled = true; + }; + + shortcuts.Add(allPropertiesShortcut); + + + if (_applicationData.Verbose || _applicationData.Debug) + { + shortcuts.Add(new Shortcut(Key.Empty, $" v{_applicationData.ModuleVersion}", null)); + var tgFileVersionInfo = FileVersionInfo.GetVersionInfo(Assembly.GetAssembly(typeof(Application))!.Location); + var tgVersion = tgFileVersionInfo?.FileVersion ?? "no version found"; + //if (tgFileVersionInfo is { IsPreRelease: true }) + { + tgVersion = tgFileVersionInfo?.ProductVersion?[..tgFileVersionInfo.ProductVersion.IndexOf('+')] ?? + tgVersion; + } + shortcuts.Add(new Shortcut(Key.Empty, $"{App?.Driver?.GetName()} v{tgVersion}", null)); + } + + _statusBar = new StatusBar(shortcuts); + Add(_statusBar); + } + + #endregion + + #region Layout Calculation + + /// + /// Calculates the natural column widths needed to display all data without truncation. + /// + /// The column headers for the grid. + /// An array of column widths where each width is the maximum needed for that column. + private int[] CalculateNaturalColumnWidths(List? gridHeaders) + { + if (gridHeaders is null || _dataTable is null) + return []; + + var columnWidths = new int[gridHeaders.Count]; + + // Start with header widths + for (var i = 0; i < gridHeaders.Count; i++) + columnWidths[i] = gridHeaders[i].Length; + + // Expand to fit data + foreach (var row in _dataTable.Data) + for (var i = 0; i < _dataTable.DataColumns.Count; i++) + { + var columnKey = _dataTable.DataColumns[i].ToString(); + if (row.Values.TryGetValue(columnKey, out var value)) + { + var len = value.DisplayValue.Length; + if (len > columnWidths[i]) + columnWidths[i] = len; + } + } + + return columnWidths; + } + + #endregion +} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutTableViewDataSource.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutTableViewDataSource.cs new file mode 100644 index 0000000..6d11a74 --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutTableViewDataSource.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.PowerShell.OutGridView.Models; +using Terminal.Gui.Views; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// An implementation that wraps the OutGridView model. +/// Supports thread-safe row addition for streaming pipeline support (Issue #209). +/// +internal sealed class OutTableViewDataSource : ITableSource +{ + private readonly List _columns; + private readonly List _rows; + private readonly object _lock = new(); + + /// + /// Creates an empty data source with the specified columns. + /// + public OutTableViewDataSource(List columns) + { + _columns = columns; + _rows = new List(); + } + + /// + /// Creates a data source with pre-populated rows. + /// + public OutTableViewDataSource(List columns, List rows) + { + _columns = columns; + _rows = new List(rows); + } + + /// + public string[] ColumnNames => _columns.Select(c => c.Label).ToArray(); + + /// + public int Columns => _columns.Count; + + /// + public int Rows + { + get { lock (_lock) return _rows.Count; } + } + + /// + public object this[int row, int col] + { + get + { + lock (_lock) + { + if (row < 0 || row >= _rows.Count || col < 0 || col >= _columns.Count) + return string.Empty; + + var dataRow = _rows[row]; + var columnKey = _columns[col].ToString(); + return dataRow.Values.TryGetValue(columnKey, out var value) + ? value.DisplayValue + : string.Empty; + } + } + } + + /// + /// Thread-safely adds a row to the data source. + /// + public void AddRow(DataTableRow row) + { + lock (_lock) _rows.Add(row); + } + + /// + /// Gets the original object index for the specified row. + /// + public int GetOriginalObjectIndex(int row) + { + lock (_lock) return row >= 0 && row < _rows.Count ? _rows[row].OriginalObjectIndex : -1; + } + + /// + /// Gets a snapshot of all rows. + /// + public List GetAllRows() + { + lock (_lock) return new List(_rows); + } + + /// + /// Gets the column definitions. + /// + public List GetColumns() => _columns; + + /// + /// Creates a new filtered data source containing only rows matching the regex pattern. + /// Rows whose original index is in are always included (and appear first). + /// + public OutTableViewDataSource Filter(string pattern, HashSet markedIndexes) + { + List allRows; + lock (_lock) allRows = new List(_rows); + + if (string.IsNullOrEmpty(pattern)) + return new OutTableViewDataSource(_columns, allRows); + + var result = new List(); + + // Marked items first (always shown) + result.AddRange(allRows.Where(r => markedIndexes.Contains(r.OriginalObjectIndex))); + + // Then unmarked items matching the filter + result.AddRange(allRows.Where(r => + !markedIndexes.Contains(r.OriginalObjectIndex) && + RowMatchesFilter(r, pattern))); + + return new OutTableViewDataSource(_columns, result); + } + + /// + /// Creates a data source from an existing . + /// + public static OutTableViewDataSource FromDataTable(DataTable dataTable) + { + return new OutTableViewDataSource(dataTable.DataColumns, dataTable.Data); + } + + private bool RowMatchesFilter(DataTableRow row, string pattern) + { + foreach (var column in _columns) + { + var columnKey = column.ToString(); + if (row.Values.TryGetValue(columnKey, out var value) && + Regex.IsMatch(value.DisplayValue, pattern, RegexOptions.IgnoreCase)) + return true; + } + + return false; + } +} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/OutTableViewWindow.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/OutTableViewWindow.cs new file mode 100644 index 0000000..a90b645 --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/OutTableViewWindow.cs @@ -0,0 +1,507 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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.Configuration; +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 using +/// with filtering, marking, and streaming support. +/// +internal sealed class OutTableViewWindow : Runnable> +{ + private const string FILTER_LABEL = "_Filter:"; + + private readonly ApplicationData _applicationData; + private OutTableViewDataSource _masterDataSource; + private OutTableViewDataSource? _filteredDataSource; + private TableView? _tableView; + private TextField? _filterField; + private Label? _filterLabel; + private View? _filterErrorView; + private StatusBar? _statusBar; + + /// + /// Tracks marked rows by their original object index. Persists across filter changes. + /// + private readonly HashSet _markedOriginalIndexes = new(); + + private bool _pipelineComplete; + private bool _isLoading = true; + private bool _useMarks; + + /// + /// Initializes a new instance of the class. + /// + /// The configuration and data to display. + /// The data source (may grow during streaming). + public OutTableViewWindow(ApplicationData applicationData, OutTableViewDataSource dataSource) + { + _applicationData = applicationData; + _masterDataSource = dataSource; + _useMarks = _applicationData.OutputMode != OutputModeOption.None; + + Title = _applicationData.Title ?? "Out-ConsoleTableView"; + SchemeName = SchemeManager.SchemesToSchemeName(Schemes.Base); + BorderStyle = Window.DefaultBorderStyle; + + if (_applicationData.MinUI) + { + BorderStyle = LineStyle.None; + } + else + { + AddFilter(); + } + + AddTableView(); + ApplyFilter(); + } + + protected override void OnIsRunningChanged(bool newIsRunning) + { + base.OnIsRunningChanged(newIsRunning); + if (!newIsRunning) return; + + AddStatusBar(); + _tableView?.SetFocus(); + } + + /// + /// Called (via Application.Invoke) when new rows have been added to the master data source during streaming. + /// + public void OnDataChanged() + { + ApplyFilter(); + UpdateStreamingStatus(); + } + + /// + /// Called (via Application.Invoke) when the pipeline has finished sending objects. + /// + public void OnPipelineComplete() + { + _pipelineComplete = true; + _isLoading = false; + UpdateStreamingStatus(); + } + + /// + /// Gets the original indexes of all marked rows. + /// + public HashSet GetSelectedIndexes() => new(_markedOriginalIndexes); + + #region Filtering + + private void ApplyFilter() + { + // Save the currently selected row's original index so we can restore position + int? selectedOriginalIndex = null; + if (_filteredDataSource != null && _tableView != null && _tableView.SelectedRow >= 0 && + _tableView.SelectedRow < _filteredDataSource.Rows) + { + selectedOriginalIndex = _filteredDataSource.GetOriginalObjectIndex(_tableView.SelectedRow); + } + + try + { + if (_filterErrorView != null) _filterErrorView.Text = string.Empty; + _filteredDataSource = _masterDataSource.Filter( + _applicationData.Filter ?? string.Empty, + _markedOriginalIndexes); + } + catch (RegexParseException ex) + { + if (_filterErrorView != null) _filterErrorView.Text = ex.Message; + return; + } + + // Set the table source (with or without mark column) + RebuildTableSource(); + + // Restore selection position + if (selectedOriginalIndex.HasValue && _filteredDataSource != null && _tableView != null) + { + for (var i = 0; i < _filteredDataSource.Rows; i++) + { + if (_filteredDataSource.GetOriginalObjectIndex(i) == selectedOriginalIndex.Value) + { + _tableView.SelectedRow = i; + break; + } + } + } + + _tableView?.Update(); + } + + private void RebuildTableSource() + { + if (_filteredDataSource == null || _tableView == null) return; + + if (_useMarks) + { + _tableView.Table = new MarkedTableSource(_filteredDataSource, _markedOriginalIndexes); + } + else + { + _tableView.Table = _filteredDataSource; + } + } + + #endregion + + #region User Actions + + private void ReloadDataWithAllProperties(bool allProperties) + { + _applicationData.AllProperties = allProperties; + + if (_applicationData.PSObjects is not { Count: > 0 }) return; + + _isLoading = true; + UpdateStatusBar(); + + var psObjects = _applicationData.PSObjects.Cast().ToList(); + var newDataTable = TypeGetter.CastObjectsToTableView(psObjects, allProperties); + + _masterDataSource = OutTableViewDataSource.FromDataTable(newDataTable); + ApplyFilter(); + + _isLoading = false; + UpdateStatusBar(); + SetNeedsLayout(); + SetNeedsDraw(); + } + + private void ToggleCurrentRowMark() + { + if (_tableView == null || _filteredDataSource == null) return; + var row = _tableView.SelectedRow; + if (row < 0 || row >= _filteredDataSource.Rows) return; + + var origIdx = _filteredDataSource.GetOriginalObjectIndex(row); + if (origIdx < 0) return; + + if (_applicationData.OutputMode == OutputModeOption.Single) + { + // In single mode, clear others first + _markedOriginalIndexes.Clear(); + _markedOriginalIndexes.Add(origIdx); + } + else + { + if (!_markedOriginalIndexes.Remove(origIdx)) + _markedOriginalIndexes.Add(origIdx); + } + + RebuildTableSource(); + _tableView.Update(); + } + + private void MarkAll() + { + if (_filteredDataSource == null) return; + + for (var i = 0; i < _filteredDataSource.Rows; i++) + _markedOriginalIndexes.Add(_filteredDataSource.GetOriginalObjectIndex(i)); + + RebuildTableSource(); + _tableView?.Update(); + } + + private void UnmarkAll() + { + _markedOriginalIndexes.Clear(); + RebuildTableSource(); + _tableView?.Update(); + } + + private void Accept() + { + Result = GetSelectedIndexes(); + App?.RequestStop(); + } + + private void Close() + { + Result = null; + App?.RequestStop(); + } + + #endregion + + #region UI Construction + + private void AddFilter() + { + _filterLabel = new Label + { + Text = FILTER_LABEL, + X = 0, + 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 = SchemeManager.SchemesToSchemeName(Schemes.Error) + }; + + _filterField.TextChanged += (_, _) => + { + try + { + if (_filterErrorView != null) _filterErrorView.Text = string.Empty; + _applicationData.Filter = _filterField.Text; + ApplyFilter(); + } + catch (Exception ex) + { + if (_filterErrorView != null) _filterErrorView.Text = ex.Message; + } + }; + + Add(_filterLabel, _filterField, _filterErrorView); + _filterField.Text = _applicationData.Filter ?? string.Empty; + _filterField.InsertionPoint = _filterField.Text.Length; + } + + private void AddTableView() + { + _tableView = new TableView + { + X = 0, + Y = _filterErrorView is { } ? Pos.Bottom(_filterErrorView) : 1, + Width = Dim.Fill(), + Height = Dim.Fill(1), + FullRowSelect = true, + MultiSelect = false, + Style = new TableStyle + { + ShowHeaders = true, + AlwaysShowHeaders = true, + ExpandLastColumn = true, + ShowHorizontalHeaderUnderline = true, + SmoothHorizontalScrolling = true + }, + ViewportSettings = ViewportSettingsFlags.HasScrollBars + }; + + // Handle keyboard shortcuts via KeyDown event + _tableView.KeyDown += OnTableViewKeyDown; + + // Enter key activates selection + if (_applicationData.OutputMode != OutputModeOption.None) + { + _tableView.CellActivated += (_, _) => Accept(); + } + + Add(_tableView); + } + + private void OnTableViewKeyDown(object? sender, Key e) + { + if (_useMarks && e == Key.Space) + { + ToggleCurrentRowMark(); + e.Handled = true; + } + else if (_applicationData.OutputMode == OutputModeOption.Multiple && e == Key.A.WithCtrl) + { + MarkAll(); + e.Handled = true; + } + else if (_applicationData.OutputMode == OutputModeOption.Multiple && e == Key.D.WithCtrl) + { + UnmarkAll(); + e.Handled = true; + } + else if (e == Key.Esc) + { + Close(); + e.Handled = true; + } + else if (_applicationData.OutputMode != OutputModeOption.None && e == Key.Enter) + { + if (MostFocused == _filterField) + { + _tableView?.SetFocus(); + } + else + { + Accept(); + } + + e.Handled = true; + } + } + + private void AddStatusBar() + { + if (_applicationData.MinUI) return; + + var shortcuts = new List(); + + // Spinner as 1st item while loading (streaming or reloading) + if (_isLoading) + { + var rowCount = _masterDataSource.Rows; + shortcuts.Add(new Shortcut + { + CommandView = new SpinnerView { AutoSpin = true }, + HelpText = $"{rowCount} rows", + CanFocus = false + }); + } + + if (_applicationData.OutputMode != OutputModeOption.None) + shortcuts.Add(new Shortcut(Key.Space, "Mark", null)); + + if (_applicationData.OutputMode == OutputModeOption.Multiple) + { + shortcuts.Add(new Shortcut(Key.A.WithCtrl, "Sel. All", null)); + shortcuts.Add(new Shortcut(Key.D.WithCtrl, "Sel. None", null)); + } + + if (_applicationData.OutputMode != OutputModeOption.None) + shortcuts.Add(new Shortcut(Key.Enter, "Accept", null)); + + shortcuts.Add(new Shortcut(Key.Esc, "Close", null)); + + // AllProperties checkbox in status bar + var allPropertiesShortcut = new Shortcut + { + CommandView = new CheckBox + { + Title = "A_ll Properties", + Value = _applicationData.AllProperties ? CheckState.Checked : CheckState.UnChecked, + CanFocus = false, + MouseHighlightStates = MouseState.None + }, + CanFocus = false, + BindKeyToApplication = true + }; + + allPropertiesShortcut.Accepting += (_, e) => + { + ReloadDataWithAllProperties(!_applicationData.AllProperties); + e.Handled = true; + }; + + shortcuts.Add(allPropertiesShortcut); + + if (_applicationData.Verbose || _applicationData.Debug) + { + shortcuts.Add(new Shortcut(Key.Empty, $" v{_applicationData.ModuleVersion}", null)); + var tgFileVersionInfo = + FileVersionInfo.GetVersionInfo(Assembly.GetAssembly(typeof(Application))!.Location); + var tgVersion = tgFileVersionInfo?.FileVersion ?? "no version found"; + { + tgVersion = tgFileVersionInfo?.ProductVersion?[..tgFileVersionInfo.ProductVersion.IndexOf('+')] ?? + tgVersion; + } + shortcuts.Add(new Shortcut(Key.Empty, $"{App?.Driver?.GetName()} v{tgVersion}", null)); + } + + _statusBar = new StatusBar(shortcuts); + Add(_statusBar); + } + + private void UpdateStatusBar() + { + if (_statusBar == null || _applicationData.MinUI) return; + + Remove(_statusBar); + _statusBar.Dispose(); + _statusBar = null; + AddStatusBar(); + } + + private void UpdateStreamingStatus() + { + if (_applicationData.MinUI) return; + UpdateStatusBar(); + } + + #endregion + + #region MarkedTableSource + + /// + /// Wraps an and prepends a mark indicator column. + /// Marked rows show ">" in the first column. + /// + private sealed class MarkedTableSource : ITableSource + { + private readonly OutTableViewDataSource _inner; + private readonly HashSet _markedOriginalIndexes; + + public MarkedTableSource(OutTableViewDataSource inner, HashSet markedOriginalIndexes) + { + _inner = inner; + _markedOriginalIndexes = markedOriginalIndexes; + } + + public string[] ColumnNames + { + get + { + var inner = _inner.ColumnNames; + var result = new string[inner.Length + 1]; + result[0] = " "; + Array.Copy(inner, 0, result, 1, inner.Length); + return result; + } + } + + public int Columns => _inner.Columns + 1; + public int Rows => _inner.Rows; + + public object this[int row, int col] + { + get + { + if (col == 0) + { + var origIdx = _inner.GetOriginalObjectIndex(row); + return _markedOriginalIndexes.Contains(origIdx) ? ">" : " "; + } + + return _inner[row, col - 1]; + } + } + } + + #endregion +} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json new file mode 100644 index 0000000..0b2ceef --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json @@ -0,0 +1,47 @@ +{ + "profiles": { + "OCGV": { + "commandName": "Executable", + "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView -Debug }\"", + "workingDirectory": "$(TargetDir)" + }, + "OCGV -Filter": { + "commandName": "Executable", + "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Select-Object ProcessName, Id, CPU | Out-ConsoleGridView -Debug -Filter com }\"", + "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)" + }, + "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)" + }, + "OCGV Select-Object": { + "commandName": "Executable", + "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Select-Object ProcessName, Id, Handles, NPM, PM, WS, CPU | Out-ConsoleGridView }\"", + "workingDirectory": "$(TargetDir)" + }, + "gci | OCGV": { + "commandName": "Executable", + "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; gci | Out-ConsoleGridView -OutputMode: Single }\"", + "workingDirectory": "$(TargetDir)" + }, + "SHOT": { + "commandName": "Executable", + "executablePath": "C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Show-ObjectTree }\"", + "workingDirectory": "$(TargetDir)" + } + } +} + diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/RegexTreeViewTextFilter.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/RegexTreeViewTextFilter.cs new file mode 100644 index 0000000..a7574dd --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/RegexTreeViewTextFilter.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Text.RegularExpressions; +using Terminal.Gui.Views; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Provides regex-based filtering for a TreeView, allowing users to filter tree nodes by matching their display text against a regular expression pattern. +/// +/// The parent ShowObjectTreeView that owns this filter. +/// The TreeView to apply filtering to. +internal sealed class RegexTreeViewTextFilter(ShowObjectTreeWindow parent, TreeView forTree) : ITreeViewFilter +{ + #region Fields + + private readonly TreeView _forTree = forTree ?? throw new ArgumentNullException(nameof(forTree)); + private string _text = string.Empty; + + #endregion + + #region Properties + + /// + /// Gets or sets the regex pattern text used for filtering. + /// + public string Text + { + get => _text; + set + { + _text = value; + RefreshTreeView(); + } + } + + #endregion + + #region ITreeViewFilter Implementation + + /// + /// Determines whether the specified model object matches the current filter criteria. + /// + /// The model object to test against the filter. + /// if the object matches the filter or no filter is set; otherwise, . + public bool IsMatch(object model) + { + if (string.IsNullOrWhiteSpace(Text)) + return true; + + var modelText = _forTree.AspectGetter(model); + try + { + var isMatch = Regex.IsMatch(modelText ?? string.Empty, Text, RegexOptions.IgnoreCase); + parent.SetRegexError(string.Empty); + return isMatch; + } + catch (RegexParseException e) + { + parent.SetRegexError(e.Message); + return false; + } + } + + #endregion + + #region Private Methods + + /// + /// Refreshes the tree view to apply the updated filter. + /// + private void RefreshTreeView() + { + _forTree.InvalidateLineMap(); + _forTree.SetNeedsDraw(); + } + + #endregion +} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs index 5b2f95d..676f0f9 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeCmdletCommand.cs @@ -4,153 +4,160 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using System.Management.Automation; using System.Management.Automation.Internal; - -using OutGridView.Models; - -namespace OutGridView.Cmdlet +using Microsoft.PowerShell.OutGridView.Models; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Displays objects in a hierarchical tree view in a separate console window. This class is invoked by PowerShell when +/// the +/// Show-ObjectTree cmdlet is called. +/// +[Cmdlet("Show", "ObjectTree")] +[Alias("shot")] +public class ShowObjectTreeCmdletCommand : PSCmdlet, IDisposable { - [Cmdlet("Show", "ObjectTree")] - [Alias("shot")] - public class ShowObjectTreeCmdletCommand : PSCmdlet, IDisposable + #region Properties + + private const string DATA_NOT_QUALIFIED_FOR_SHOW_OBJECT_TREE = nameof(DATA_NOT_QUALIFIED_FOR_SHOW_OBJECT_TREE); + + private const string ENVIRONMENT_NOT_SUPPORTED_FOR_SHOW_OBJECT_TREE = + nameof(ENVIRONMENT_NOT_SUPPORTED_FOR_SHOW_OBJECT_TREE); + + private readonly List _psObjects = []; + + #endregion Properties + + #region Input Parameters + + /// + /// Gets or sets the current pipeline object. + /// + [Parameter(ValueFromPipeline = true, HelpMessage = "Specifies the input pipeline object")] + public PSObject InputObject { get; set; } = AutomationNull.Value; + + /// + /// Gets or sets the title of the Show-ObjectTree window. + /// + [Parameter(HelpMessage = + "Specifies the text that appears in the title bar of the Show-ObjectTree window. By default, the title bar displays the command that invokes Show-ObjectTree.")] + [ValidateNotNullOrEmpty] + public string? Title { get; set; } + + /// + /// Gets or sets the initial value for the filter in the GUI. + /// + [Parameter(HelpMessage = + "Pre-populates the Filter edit box, allowing filtering to be specified on the command line. The filter uses regular expressions.")] + public string? Filter { set; get; } + + /// + /// Gets or sets a value indicating whether "minimum UI" mode will be enabled. + /// + [Parameter(HelpMessage = "If specified no window frame, filter box, or status bar will be displayed in the TUI.")] + public SwitchParameter MinUI { set; get; } + + /// + /// Gets or sets the Terminal.Gui driver to use. + /// + [Parameter(HelpMessage = + "Forces the Terminal.Gui driver to use. Valid values are 'ansi', 'windows', or 'unix'.")] + public string? ForceDriver { set; get; } + + /// + /// Gets a value indicating whether the Debug switch is present. + /// + public bool Debug => MyInvocation.BoundParameters.ContainsKey("Debug"); + + #endregion Input Parameters + + /// + /// Performs initialization of command execution. Validates that the environment supports object tree view. + /// + protected override void BeginProcessing() { - #region Properties - - private const string DataNotQualifiedForShowObjectTree = nameof(DataNotQualifiedForShowObjectTree); - private const string EnvironmentNotSupportedForShowObjectTree = nameof(EnvironmentNotSupportedForShowObjectTree); - - private List _psObjects = new List(); - - #endregion Properties - - #region Input Parameters - - /// - /// This parameter specifies the current pipeline object. - /// - [Parameter(ValueFromPipeline = true, HelpMessage = "Specifies the input pipeline object")] - public PSObject InputObject { get; set; } = AutomationNull.Value; - - /// - /// Gets/sets the title of the Out-GridView window. - /// - [Parameter(HelpMessage = "Specifies the text that appears in the title bar of the Out-ConsoleGridView window. y default, the title bar displays the command that invokes Out-ConsoleGridView.")] - [ValidateNotNullOrEmpty] - public string Title { get; set; } - - /// - /// gets or sets the initial value for the filter in the GUI - /// - [Parameter(HelpMessage = "Pre-populates the Filter edit box, allowing filtering to be specified on the command line. The filter uses regular expressions.")] - public string Filter { set; get; } - - /// - /// gets or sets the whether "minimum UI" mode will be enabled - /// - [Parameter(HelpMessage = "If specified no window frame, filter box, or status bar will be displayed in the GUI.")] - public SwitchParameter MinUI { set; get; } - /// - /// gets or sets the whether the Terminal.Gui System.Net.Console-based ConsoleDriver will be used instead of the - /// default platform-specific (Windows or Curses) ConsoleDriver. - /// - [Parameter(HelpMessage = "If specified the Terminal.Gui System.Net.Console-based ConsoleDriver (NetDriver) will be used.")] - public SwitchParameter UseNetDriver { set; get; } - - /// - /// For the -Debug switch - /// - public bool Debug => MyInvocation.BoundParameters.TryGetValue("Debug", out var o); - - #endregion Input Parameters - - // This method gets called once for each cmdlet in the pipeline when the pipeline starts executing - protected override void BeginProcessing() + if (Console.IsInputRedirected) { - if (Console.IsInputRedirected) - { - ErrorRecord error = new ErrorRecord( - new PSNotSupportedException("Not supported in this environment (when input is redirected)."), - EnvironmentNotSupportedForShowObjectTree, - ErrorCategory.NotImplemented, - null); - - ThrowTerminatingError(error); - } - } + var error = new ErrorRecord( + new PSNotSupportedException("Not supported in this environment (when input is redirected)."), + ENVIRONMENT_NOT_SUPPORTED_FOR_SHOW_OBJECT_TREE, + ErrorCategory.NotImplemented, + null); - // This method will be called for each input received from the pipeline to this cmdlet; if no input is received, this method is not called - protected override void ProcessRecord() - { - if (InputObject == null || InputObject == AutomationNull.Value) - { - return; - } - - if (InputObject.BaseObject is IDictionary dictionary) - { - // Dictionaries should be enumerated through because the pipeline does not enumerate through them. - foreach (DictionaryEntry entry in dictionary) - { - ProcessObject(PSObject.AsPSObject(entry)); - } - } - else - { - ProcessObject(InputObject); - } + ThrowTerminatingError(error); } + } + + /// + /// Processes each input object received from the pipeline. + /// + protected override void ProcessRecord() + { + if (Equals(InputObject, AutomationNull.Value)) return; + + if (InputObject.BaseObject is IDictionary dictionary) + // Dictionaries should be enumerated through because the pipeline does not enumerate through them. + foreach (DictionaryEntry entry in dictionary) + ProcessObject(PSObject.AsPSObject(entry)); + else + ProcessObject(InputObject); + } + + private void ProcessObject(PSObject input) + { + var baseObject = input.BaseObject; - private void ProcessObject(PSObject input) + // Throw a terminating error for types that are not supported. + if (baseObject is ScriptBlock || + baseObject is SwitchParameter || + baseObject is PSReference || + baseObject is PSObject) { + var error = new ErrorRecord( + new FormatException("Invalid data type for Show-ObjectTree"), + DATA_NOT_QUALIFIED_FOR_SHOW_OBJECT_TREE, + ErrorCategory.InvalidType, + null); - object baseObject = input.BaseObject; + ThrowTerminatingError(error); + } - // Throw a terminating error for types that are not supported. - if (baseObject is ScriptBlock || - baseObject is SwitchParameter || - baseObject is PSReference || - baseObject is PSObject) - { - ErrorRecord error = new ErrorRecord( - new FormatException("Invalid data type for Show-ObjectTree"), - DataNotQualifiedForShowObjectTree, - ErrorCategory.InvalidType, - null); + _psObjects.Add(input); + } - ThrowTerminatingError(error); - } + /// + /// Performs final processing after all pipeline objects have been received. + /// Displays the object tree view with all collected objects. + /// + protected override void EndProcessing() + { + base.EndProcessing(); - _psObjects.Add(input); - } + // Return if no objects + if (_psObjects.Count == 0) return; - // This method will be called once at the end of pipeline execution; if no input is received, this method is not called - protected override void EndProcessing() + var applicationData = new ApplicationData { - base.EndProcessing(); - - //Return if no objects - if (_psObjects.Count == 0) - { - return; - } - - var applicationData = new ApplicationData - { - Title = Title ?? "Show-ObjectTree", - Filter = Filter, - MinUI = MinUI, - UseNetDriver = UseNetDriver, - Debug = Debug, - ModuleVersion = MyInvocation.MyCommand.Version.ToString() - }; - - ShowObjectView.Run(_psObjects, applicationData); - } + PSObjects = _psObjects.Cast().ToList(), + Title = Title ?? "Show-ObjectTree", + Filter = Filter, + MinUI = MinUI, + ForceDriver = ForceDriver, + Debug = Debug, + ModuleVersion = MyInvocation.MyCommand.Version.ToString() + }; + + ShowObjectView.Run(applicationData); + } - public void Dispose() - { - GC.SuppressFinalize(this); - } + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + GC.SuppressFinalize(this); } -} +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeWindow.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeWindow.cs new file mode 100644 index 0000000..59f61c5 --- /dev/null +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectTreeWindow.cs @@ -0,0 +1,362 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using System.Text.RegularExpressions; +using System.IO; +using System.Linq; +using System.Management.Automation; +using Terminal.Gui.App; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; +using Terminal.Gui.Drawing; +using Terminal.Gui.Input; +using Terminal.Gui.Configuration; +using Microsoft.PowerShell.OutGridView.Models; + +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 +{ + private const string FILTER_LABEL = "_Filter:"; + + #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); + + 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_LABEL, + X = 1 + }; + + var filterTextField = new TextField + { + Text = _applicationData.Filter ?? string.Empty, + X = Pos.Right(filterLabel) + 1, + Width = Dim.Fill(1), + InsertionPoint= (_applicationData.Filter ?? string.Empty).Length + }; + + _filterErrorView = new Label + { + SchemeName = SchemeManager.SchemesToSchemeName(Schemes.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); + } + + protected override void OnIsRunningChanged(bool newIsRunning) + { + base.OnIsRunningChanged(newIsRunning); + if (!newIsRunning) return; + + // We do this here, because _statusBar requires the Application to be running to + // access the driver information. + Add(_statusBar); + _tree.SetFocus(); + } + + #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; + + _selectedShortcut?.Title = selectedValue != null ? selectedValue.GetType().Name : 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 []; + + switch (forObject) + { + case CachedMemberResult { IsCollection: true } p: + return p.Elements ?? Enumerable.Empty(); + case CachedMemberResult p: + forObject = p.Value; + continue; + case 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) + { + return toRender switch + { + Process p => p.ProcessName, + null => "Null", + FileSystemInfo fsi when !IsRootObject(fsi) => fsi.Name, + _ => 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", () => App?.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.Verbose || _applicationData.Debug) + { + shortcuts.Add(new Shortcut(Key.Empty, $" v{_applicationData.ModuleVersion}", null)); + FileVersionInfo tgFileVersionInfo = FileVersionInfo.GetVersionInfo(Assembly.GetAssembly(typeof(Application))!.Location); + string tgVersion = tgFileVersionInfo?.FileVersion ?? "no version found"; + //if (tgFileVersionInfo is { IsPreRelease: true }) + { + tgVersion = tgFileVersionInfo?.ProductVersion?[..tgFileVersionInfo.ProductVersion.IndexOf('+')] ?? tgVersion; + } + shortcuts.Add(new Shortcut(Key.Empty, + $"{App?.Driver?.GetName()} v{tgVersion}", + null)); + } + + return shortcuts; + } + + #endregion +} diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs index 989cab1..b94baf6 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/ShowObjectView.cs @@ -2,461 +2,59 @@ // Licensed under the MIT License. using System; -using System.Collections; using System.Collections.Generic; -using System.Diagnostics; -using System.IO; using System.Linq; using System.Management.Automation; -using System.Reflection; -using System.Text.RegularExpressions; - -using OutGridView.Models; - -using Terminal.Gui; -using Terminal.Gui.Trees; - -namespace OutGridView.Cmdlet +using Microsoft.PowerShell.OutGridView.Models; +using Terminal.Gui.App; + +namespace Microsoft.PowerShell.ConsoleGuiTools; + +/// +/// Provides the main orchestration for the Show-ObjectTree cmdlet, managing the Terminal.Gui application lifecycle +/// and coordinating between the application data and the tree view window. +/// +/// +/// This class serves as a facade that initializes the Terminal.Gui framework, creates and runs the tree view window, +/// and handles cleanup operations. It delegates the actual UI rendering and user interaction to the class. +/// +internal sealed class ShowObjectView : IDisposable { - internal sealed class ShowObjectView : Window, ITreeBuilder + /// + /// Runs the Show-ObjectView Terminal.Gui Application with the specified configuration. + /// + /// The application configuration containing the PSObjects and display options. + /// + /// + /// This method initializes the Terminal.Gui framework, creates a instance, + /// and runs it until the user closes the window. The method handles the complete application lifecycle + /// including initialization, execution, and shutdown. + /// + /// + internal static void Run(ApplicationData applicationData) { - private readonly TreeView tree; - private readonly RegexTreeViewTextFilter filter; - private readonly Label filterErrorLabel; - - public bool SupportsCanExpand => true; - private StatusItem selectedStatusBarItem; - private StatusBar statusBar; - - public ShowObjectView(List rootObjects, ApplicationData applicationData) - { - Title = applicationData.Title; - Width = Dim.Fill(); - Height = Dim.Fill(1); - Modal = false; - - - if (applicationData.MinUI) - { - Border.BorderStyle = BorderStyle.None; - Title = string.Empty; - X = -1; - Height = Dim.Fill(); - } - - tree = new TreeView - { - Y = applicationData.MinUI ? 0 : 2, - Width = Dim.Fill(), - Height = Dim.Fill(), - }; - tree.TreeBuilder = this; - tree.AspectGetter = this.AspectGetter; - tree.SelectionChanged += this.SelectionChanged; - - tree.ClearKeybinding(Command.ExpandAll); - - this.filter = new RegexTreeViewTextFilter(this, tree); - this.filter.Text = applicationData.Filter ?? string.Empty; - tree.Filter = this.filter; - - if (rootObjects.Count > 0) - { - tree.AddObjects(rootObjects); - } - else - { - tree.AddObject("No Objects"); - } - statusBar = new StatusBar(); - - string elementDescription = "objects"; - - var types = rootObjects.Select(o => o.GetType()).Distinct().ToArray(); - if (types.Length == 1) - { - elementDescription = types[0].Name; - } - - var lblFilter = new Label() - { - Text = "Filter:", - X = 1, - }; - var tbFilter = new TextField() - { - X = Pos.Right(lblFilter), - Width = Dim.Fill(1), - Text = applicationData.Filter ?? string.Empty - }; - tbFilter.CursorPosition = tbFilter.Text.Length; - - tbFilter.TextChanged += (_) => - { - filter.Text = tbFilter.Text.ToString(); - }; - - - filterErrorLabel = new Label(string.Empty) - { - X = Pos.Right(lblFilter) + 1, - Y = Pos.Top(lblFilter) + 1, - ColorScheme = Colors.Base, - Width = Dim.Fill() - lblFilter.Text.Length - }; - - if (!applicationData.MinUI) - { - Add(lblFilter); - Add(tbFilter); - Add(filterErrorLabel); - } - - int pos = 0; - statusBar.AddItemAt(pos++, new StatusItem(Key.Esc, "~ESC~ Close", () => Application.RequestStop())); - - var siCount = new StatusItem(Key.Null, $"{rootObjects.Count} {elementDescription}", null); - selectedStatusBarItem = new StatusItem(Key.Null, string.Empty, null); - statusBar.AddItemAt(pos++, siCount); - statusBar.AddItemAt(pos++, selectedStatusBarItem); - - if (applicationData.Debug) - { - statusBar.AddItemAt(pos++, new StatusItem(Key.Null, $" v{applicationData.ModuleVersion}", null)); - statusBar.AddItemAt(pos++, new StatusItem(Key.Null, - $"{Application.Driver} v{FileVersionInfo.GetVersionInfo(Assembly.GetAssembly(typeof(Application)).Location).ProductVersion}", null)); - } - - statusBar.Visible = !applicationData.MinUI; - Application.Top.Add(statusBar); - - Add(tree); - } - private void SetRegexError(string error) - { - if (string.Equals(error, filterErrorLabel.Text.ToString(), StringComparison.Ordinal)) - { - return; - } - filterErrorLabel.Text = error; - filterErrorLabel.ColorScheme = Colors.Error; - filterErrorLabel.Redraw(filterErrorLabel.Bounds); - } - - private void SelectionChanged(object sender, SelectionChangedEventArgs e) - { - var selectedValue = e.NewValue; - - if (selectedValue is CachedMemberResult cmr) - { - selectedValue = cmr.Value; - } - - if (selectedValue != null && selectedStatusBarItem != null) - { - selectedStatusBarItem.Title = selectedValue.GetType().Name; - } - else - { - selectedStatusBarItem.Title = string.Empty; - } - - statusBar.SetNeedsDisplay(); - } - - private string AspectGetter(object toRender) - { - if (toRender is Process p) - { - return p.ProcessName; - } - if (toRender is null) - { - return "Null"; - } - if (toRender is FileSystemInfo fsi && !IsRootObject(fsi)) - { - return fsi.Name; - } - - return toRender.ToString(); - } - - private bool IsRootObject(object o) - { - return tree.Objects.Contains(o); - } - - public bool CanExpand(object toExpand) - { - if (toExpand is CachedMemberResult p) - { - return IsBasicType(p?.Value); - } - - // Any complex object type can be expanded to reveal properties - return IsBasicType(toExpand); - } - - private static bool IsBasicType(object value) - { - return value != null && value is not string && !value.GetType().IsValueType; - } - - public IEnumerable GetChildren(object forObject) - { - if (forObject == null || !this.CanExpand(forObject)) - { - return Enumerable.Empty(); - } - - if (forObject is CachedMemberResult p) - { - if (p.IsCollection) - { - return p.Elements; - } - - return GetChildren(p.Value); - } - - if (forObject is CachedMemberResultElement e) - { - return GetChildren(e.Value); - } + Terminal.Gui.Configuration.ConfigurationManager.Enable(Terminal.Gui.Configuration.ConfigLocations.All); - List children = new List(); + using ShowObjectTreeWindow window = new(applicationData); + using IApplication app = Application.Create().Init(driverName: applicationData.ForceDriver); + app.Run(window); - foreach (var member in forObject.GetType().GetMembers(BindingFlags.Instance | BindingFlags.Public).OrderBy(m => m.Name)) - { - if (member is PropertyInfo prop) - { - children.Add(new CachedMemberResult(forObject, prop)); - } - if (member is FieldInfo field) - { - children.Add(new CachedMemberResult(forObject, field)); - } - } - - try - { - children.AddRange(GetExtraChildren(forObject)); - } - catch (Exception) - { - // Extra children unavailable, possibly security or IO exceptions enumerating children etc - } - - return children; - } - - private static IEnumerable GetExtraChildren(object forObject) - { - if (forObject is DirectoryInfo dir) - { - foreach (var c in dir.EnumerateFileSystemInfos()) - { - yield return c; - } - } - } - - internal static void Run(List objects, ApplicationData applicationData) - { - // Note, in Terminal.Gui v2, this property is renamed to Application.UseNetDriver, hence - // using that terminology here. - Application.UseSystemConsole = applicationData.UseNetDriver; - Application.Init(); - Window window = null; - - try - { - window = new ShowObjectView(objects.Select(p => p.BaseObject).ToList(), applicationData); - Application.Top.Add(window); - Application.Run(); - } - finally - { - Application.Shutdown(); - window?.Dispose(); - } - } - - sealed class CachedMemberResultElement - { - public int Index; - public object Value; - - private string representation; - - public CachedMemberResultElement(object value, int index) - { - Index = index; - Value = value; - - try - { - representation = Value?.ToString() ?? "Null"; - } - catch (Exception) - { - Value = representation = "Unavailable"; - } - } - public override string ToString() - { - return $"[{Index}]: {representation}]"; - } - } - - sealed class CachedMemberResult - { - public MemberInfo Member; - public object Value; - public object Parent; - private string representation; - private List valueAsList; - - - public bool IsCollection => valueAsList != null; - public IReadOnlyCollection Elements => valueAsList?.AsReadOnly(); - - public CachedMemberResult(object parent, MemberInfo mem) - { - Parent = parent; - Member = mem; - - try - { - if (mem is PropertyInfo p) - { - Value = p.GetValue(parent); - } - else if (mem is FieldInfo f) - { - Value = f.GetValue(parent); - } - else - { - throw new NotSupportedException($"Unknown {nameof(MemberInfo)} Type"); - } - - representation = ValueToString(); - - } - catch (Exception) - { - Value = representation = "Unavailable"; - } - } - - private string ValueToString() - { - if (Value == null) - { - return "Null"; - } - try - { - if (IsCollectionOfKnownTypeAndSize(out Type elementType, out int size)) - { - return $"{elementType.Name}[{size}]"; - } - } - catch (Exception) - { - return Value?.ToString(); - } - - - return Value?.ToString(); - } - - private bool IsCollectionOfKnownTypeAndSize(out Type elementType, out int size) - { - elementType = null; - size = 0; - - if (Value == null || Value is string) - { - - return false; - } - - if (Value is IEnumerable ienumerable) - { - var list = ienumerable.Cast().ToList(); - - var types = list.Where(v => v != null).Select(v => v.GetType()).Distinct().ToArray(); - - if (types.Length == 1) - { - elementType = types[0]; - size = list.Count; - - valueAsList = list.Select((e, i) => new CachedMemberResultElement(e, i)).ToList(); - return true; - } - } - - return false; - } - - public override string ToString() - { - return Member.Name + ": " + representation; - } - } - private sealed class RegexTreeViewTextFilter : ITreeViewFilter - { - private readonly ShowObjectView parent; - readonly TreeView _forTree; - - public RegexTreeViewTextFilter(ShowObjectView parent, TreeView forTree) - { - this.parent = parent; - _forTree = forTree ?? throw new ArgumentNullException(nameof(forTree)); - } - - private string text; - - public string Text - { - get { return text; } - set - { - text = value; - RefreshTreeView(); - } - } - - private void RefreshTreeView() - { - _forTree.InvalidateLineMap(); - _forTree.SetNeedsDisplay(); - } - - public bool IsMatch(object model) - { - if (string.IsNullOrWhiteSpace(Text)) - { - return true; - } - - parent.SetRegexError(string.Empty); + // using ShowObjectTreeWindow window = new(applicationData); + // using IApplication app = Application.Create(); + // app.Init(); + // bool accepted = app.Run(window) is true; + // Application.ForceDriver = string.Empty; + } - var modelText = _forTree.AspectGetter(model); - try - { - return Regex.IsMatch(modelText, text, RegexOptions.IgnoreCase); - } - catch (RegexParseException e) - { - parent.SetRegexError(e.Message); - return true; - } - } - } + /// + /// Releases resources used by the . + /// + /// + /// Currently, there are no resources to dispose. This method is provided for future extensibility + /// and to follow the standard IDisposable pattern. + /// + public void Dispose() + { + // No resources to dispose currently } -} +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs index 6f3f643..34ad33e 100644 --- a/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs +++ b/src/Microsoft.PowerShell.ConsoleGuiTools/TypeGetter.cs @@ -1,198 +1,410 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Management.Automation; -using System.Management.Automation.Internal; - +using System.Management.Automation.Runspaces; +using System.Text.RegularExpressions; using Microsoft.PowerShell.Commands; +using Microsoft.PowerShell.OutGridView.Models; -using OutGridView.Models; +namespace Microsoft.PowerShell.ConsoleGuiTools; -namespace OutGridView.Cmdlet +/// +/// Provides methods to retrieve type information and convert PowerShell objects into data table structures for display +/// in the grid view using PowerShell's native formatting infrastructure. +/// +public class TypeGetter { - public class TypeGetter + private readonly Dictionary _formatCache = new(); + + /// + /// Regex pattern to match ANSI escape sequences. + /// + private static readonly Regex AnsiEscapeRegex = new(@"\x1b\[[0-9;]*m", RegexOptions.Compiled); + + /// + /// Strips ANSI escape sequences from a string. + /// + /// The string potentially containing ANSI codes. + /// The string with ANSI codes removed. + private static string StripAnsiCodes(string value) { - private PSCmdlet _cmdlet; + if (string.IsNullOrEmpty(value)) return value; + return AnsiEscapeRegex.Replace(value, string.Empty); + } - public TypeGetter(PSCmdlet cmdlet) - { - _cmdlet = cmdlet; - } - public FormatViewDefinition GetFormatViewDefinitionForObject(PSObject obj) + /// + /// Gets the format view definition for the specified type name, using a cache to avoid redundant lookups. + /// + /// The full type name to get the format view definition for. + /// The format view definition if found; otherwise, . + private FormatViewDefinition? GetFormatViewDefinitionForType(string typeName) + { + if (_formatCache.TryGetValue(typeName, out var cached)) return cached; + + try { - var typeName = obj.BaseObject.GetType().FullName; + // Use the current runspace to access format data from loaded modules + // This is critical for CIM instances (like Get-NetAdapter) which have format data + // defined in their respective modules' .ps1xml files + using var ps = System.Management.Automation.PowerShell.Create(RunspaceMode.CurrentRunspace); + ps.AddCommand("Get-FormatData").AddParameter("TypeName", typeName); - var types = _cmdlet.InvokeCommand.InvokeScript(@"Microsoft.PowerShell.Utility\Get-FormatData " + typeName).ToList(); + var results = ps.Invoke(); - //No custom type definitions found - try the PowerShell specific format data - if (types == null || types.Count == 0) + FormatViewDefinition? result = null; + if (results.Count > 0) { - types = _cmdlet.InvokeCommand - .InvokeScript(@"Microsoft.PowerShell.Utility\Get-FormatData -PowerShellVersion $PSVersionTable.PSVersion " + typeName).ToList(); - - if (types == null || types.Count == 0) - { - return null; - } + var extendedTypeDefinition = results[0].BaseObject as ExtendedTypeDefinition; + result = extendedTypeDefinition?.FormatViewDefinition.FirstOrDefault(v => v.Control is TableControl); } - var extendedTypeDefinition = types[0].BaseObject as ExtendedTypeDefinition; - - return extendedTypeDefinition.FormatViewDefinition[0]; + _formatCache[typeName] = result; + return result; + } + catch + { + // If we can't get format data (e.g., not running in a runspace context), + // cache null and continue with fallback logic + _formatCache[typeName] = null; + return null; } + } - public static DataTableRow CastObjectToDataTableRow(PSObject ps, List dataColumns, int objectIndex) + /// + /// Gets the format view definition for the specified PowerShell object. + /// + /// The PowerShell object to get the format view definition for. + /// The format view definition if found; otherwise, . + private FormatViewDefinition? GetFormatViewDefinitionForObject(PSObject obj) + { + // PSObject has a TypeNames collection that includes PowerShell-specific type names + // These are what the format system uses, not the .NET type name + // For example, Get-NetAdapter returns objects with TypeName like "Microsoft.Management.Infrastructure.CimInstance#ROOT/StandardCimv2/MSFT_NetAdapter" + + foreach (var typeName in obj.TypeNames) { - Dictionary valuePairs = new Dictionary(); + if (_formatCache.TryGetValue(typeName, out var cached)) + return cached; - foreach (var dataColumn in dataColumns) + var fvd = GetFormatViewDefinitionForType(typeName); + if (fvd != null) { - var expression = new PSPropertyExpression(ScriptBlock.Create(dataColumn.PropertyScriptAccessor)); + return fvd; + } + } - var result = expression.GetValues(ps).FirstOrDefault().Result; + // Fallback to base object type name + string? baseTypeName = obj.BaseObject.GetType().FullName; + if (baseTypeName is not null) + { + if (_formatCache.TryGetValue(baseTypeName, out var cached)) + return cached; - var stringValue = result?.ToString() ?? String.Empty; + return GetFormatViewDefinitionForType(baseTypeName); + } - var isDecimal = decimal.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture.NumberFormat, out var decimalValue); + return null; + } - if (isDecimal) - { - valuePairs[dataColumn.ToString()] = new DecimalValue { DisplayValue = stringValue, SortValue = decimalValue }; - } - else + /// + /// Gets the default display property set (TableContent) for a PowerShell object. + /// This represents the subset of properties that PowerShell displays by default. + /// + /// The PowerShell object to examine. + /// A list of property names to display, or null if no default display set is defined. + private static List? GetDefaultDisplayPropertySet(PSObject obj) + { + try + { + // For CIM instances and other objects, PowerShell adds PSStandardMembers + // through the Extended Type System (ETS), not always as instance members + + // First check instance members (for objects with runtime-added members) + var standardMembers = obj.Members["PSStandardMembers"]?.Value as PSMemberSet; + var defaultDisplayProperty = standardMembers?.Members["DefaultDisplayPropertySet"]?.Value as PSPropertySet; + + if (defaultDisplayProperty?.ReferencedPropertyNames != null && defaultDisplayProperty.ReferencedPropertyNames.Count > 0) + { + return defaultDisplayProperty.ReferencedPropertyNames.ToList(); + } + + // Second, check PSObject.Properties for DefaultDisplayPropertySet + // Some objects have this defined through type adapters + var psStandardMembers = obj.Properties["PSStandardMembers"]; + if (psStandardMembers?.Value is PSMemberSet memberSet) + { + var displayPropSet = memberSet.Members["DefaultDisplayPropertySet"] as PSPropertySet; + if (displayPropSet?.ReferencedPropertyNames != null && displayPropSet.ReferencedPropertyNames.Count > 0) { - var stringDecorated = new StringDecorated(stringValue); - valuePairs[dataColumn.ToString()] = new StringValue { DisplayValue = stringDecorated.ToString(OutputRendering.PlainText) }; + return displayPropSet.ReferencedPropertyNames.ToList(); } } - - return new DataTableRow(valuePairs, objectIndex); } - - private static void SetTypesOnDataColumns(List dataTableRows, List dataTableColumns) + catch { - var dataRows = dataTableRows.Select(x => x.Values); + // If we can't get the default display property set, return null + } - foreach (var dataColumn in dataTableColumns) + return null; + } + + /// + /// Retrieves the column definitions for the specified PowerShell objects based on their format view definitions or properties. + /// + /// The list of PowerShell objects to analyze. + /// If true, returns all properties instead of just the default display properties. + /// A distinct list of data table columns. + private List GetDataColumnsForObject(List psObjects, bool allProperties = false) + { + var dataColumns = new List(); + + if (psObjects.Count == 0) return dataColumns; + + var firstObject = psObjects[0]; + + List labels; + List propertyAccessors; + + // If allProperties is requested, skip format view and default display property logic + if (allProperties) + { + if (PSObjectIsPrimitive(firstObject)) + { + // Handle primitive types + labels = [firstObject.BaseObject.GetType().Name]; + propertyAccessors = ["$_"]; + } + else { - dataColumn.StringType = typeof(decimal).FullName; + // Return all properties - use simple property access format for performance + // Filter out PS* metadata properties which are often expensive to compute + var properties = firstObject.Properties + .Where(p => p.IsGettable && !p.Name.StartsWith("PS")) + .ToList(); + + labels = properties.Select(p => p.Name).ToList(); + propertyAccessors = properties.Select(p => $"$_.\"{p.Name}\"").ToList(); } + } + else + { + // Priority order: + // 1. Format view definition (from .ps1xml files) - what cmdlets like Get-NetAdapter use + // 2. DefaultDisplayPropertySet (TableContent) - used by custom objects + // 3. All properties (fallback) + + var fvd = GetFormatViewDefinitionForObject(firstObject); + var defaultDisplayProps = GetDefaultDisplayPropertySet(firstObject); - //If every value in a column could be a decimal, assume that it is supposed to be a decimal - foreach (var dataRow in dataRows) + if (fvd?.Control is TableControl tableControl) { - foreach (var dataColumn in dataTableColumns) + // Use the table format definition (THIS IS WHAT GET-NETADAPTER USES) + var definedColumnLabels = tableControl.Headers.Select(h => h.Label).ToList(); + var displayEntries = tableControl.Rows[0].Columns.Select(c => c.DisplayEntry).ToArray(); + var propertyLabels = displayEntries.Select(de => de.Value).ToList(); + + // Use the TypeDefinition Label if available otherwise just use the property name as a label + labels = definedColumnLabels.Zip(propertyLabels, (definedLabel, propLabel) => { - if (!(dataRow[dataColumn.ToString()] is DecimalValue)) - { - dataColumn.StringType = typeof(string).FullName; - } - } + if (string.IsNullOrEmpty(definedLabel)) return propLabel; + return definedLabel; + }).ToList(); + + propertyAccessors = displayEntries.Select(de => + de.ValueType == DisplayEntryValueType.Property + ? $"$_.\"{de.Value}\"" + : de.Value // ScriptBlock + ).ToList(); + } + else if (defaultDisplayProps != null && defaultDisplayProps.Count > 0) + { + // Use the DefaultDisplayPropertySet (for custom objects) + labels = defaultDisplayProps; + propertyAccessors = defaultDisplayProps.Select(p => $"$_.\"{p}\"").ToList(); + } + else if (PSObjectIsPrimitive(firstObject)) + { + // Handle primitive types + labels = [firstObject.BaseObject.GetType().Name]; + propertyAccessors = ["$_"]; + } + else + { + // Fallback to all properties + labels = firstObject.Properties.Select(p => p.Name).ToList(); + propertyAccessors = firstObject.Properties.Select(p => $"$_.\"{p.Name}\"").ToList(); } } - private List GetDataColumnsForObject(List psObjects) + + for (int i = 0; i < labels.Count; i++) { - var dataColumns = new List(); + var column = new DataTableColumn(labels[i], propertyAccessors[i]); + dataColumns.Add(column); + } + return dataColumns.Distinct().ToList(); + } + /// + /// Types that are considered primitives to PowerShell but not to C#. + /// + private static readonly List ADDITIONAL_PRIMITIVE_TYPES = + [ + "System.String", + "System.Decimal", + "System.IntPtr", + "System.Security.SecureString", + "System.Numerics.BigInteger" + ]; + + /// + /// Determines whether the specified PowerShell object represents a primitive type. + /// + private static bool PSObjectIsPrimitive(PSObject ps) + { + var psBaseType = ps.BaseObject.GetType(); + return psBaseType.IsPrimitive || psBaseType.IsEnum || + ADDITIONAL_PRIMITIVE_TYPES.Contains(psBaseType.FullName!); + } - foreach (PSObject obj in psObjects) - { - var labels = new List(); + /// + /// Converts a PowerShell object to a data table row using PSPropertyExpression to evaluate property accessors. + /// + public static DataTableRow CastObjectToDataTableRow(PSObject psObject, List dataTableColumns, int objectIndex) + { + var valuePairs = new Dictionary(); - FormatViewDefinition fvd = GetFormatViewDefinitionForObject(obj); + foreach (var column in dataTableColumns) + { + object? result = null; - var propertyAccessors = new List(); + try + { + // PSPropertyExpression constructor takes a ScriptBlock for script expressions + // For simple properties, we need to extract just the property name + var accessor = column.PropertyScriptAccessor; - if (fvd == null) + if (accessor.StartsWith("$_.\"") && accessor.EndsWith("\"")) { - if (PSObjectIsPrimitive(obj)) - { - labels = new List { obj.BaseObject.GetType().Name }; - propertyAccessors = new List { "$_" }; - } - else + // Extract property name from "$_."PropertyName"" format + var propertyName = accessor.Substring(4, accessor.Length - 5); + var property = psObject.Properties[propertyName]; + result = property?.Value; + + // Unwrap PSObject if needed to get the base value + if (result is PSObject psObjResult) { - labels = obj.Properties.Select(x => x.Name).ToList(); - propertyAccessors = obj.Properties.Select(x => $"$_.\"{x.Name}\"").ToList(); + result = psObjResult.BaseObject; } } + else if (accessor == "$_") + { + // The whole object + result = psObject.BaseObject; + } else { - var tableControl = fvd.Control as TableControl; + // It's a script block - create and invoke it + var scriptBlock = ScriptBlock.Create(accessor); + var results = scriptBlock.InvokeWithContext(null, new List + { + new PSVariable("_", psObject) + }); + result = results.FirstOrDefault(); - var definedColumnLabels = tableControl.Headers.Select(x => x.Label); + // Unwrap PSObject if needed + if (result is PSObject psScriptResult) + { + result = psScriptResult.BaseObject; + } + } + } + catch (Exception _) + { + // If evaluation fails, use null + result = null; + } - var displayEntries = tableControl.Rows[0].Columns.Select(x => x.DisplayEntry); + // Convert to string and strip ANSI codes + var displayValue = result?.ToString() ?? string.Empty; + displayValue = StripAnsiCodes(displayValue); - var propertyLabels = displayEntries.Select(x => x.Value); + // Determine if this is a numeric value for sorting + var isNumeric = result is decimal or int or long or short or byte or double or float or uint or ulong or ushort or sbyte; - //Use the TypeDefinition Label if availble otherwise just use the property name as a label - labels = definedColumnLabels.Zip(propertyLabels, (definedColumnLabel, propertyLabel) => - { - if (String.IsNullOrEmpty(definedColumnLabel)) - { - return propertyLabel; - } - return definedColumnLabel; - }).ToList(); - - - propertyAccessors = displayEntries.Select(x => - { - //If it's a propety access directly - if (x.ValueType == DisplayEntryValueType.Property) - { - return $"$_.\"{x.Value}\""; - } - //Otherwise return access script - return x.Value; - }).ToList(); - } + var columnKey = column.ToString(); - for (var i = 0; i < labels.Count; i++) + if (isNumeric) + { + var decimalValue = Convert.ToDecimal(result, CultureInfo.InvariantCulture); + valuePairs[columnKey] = new DecimalValue { - dataColumns.Add(new DataTableColumn(labels[i], propertyAccessors[i])); - } + DisplayValue = displayValue, + SortValue = decimalValue + }; + } + else + { + valuePairs[columnKey] = new StringValue + { + DisplayValue = displayValue, + RawValue = result + }; } - return dataColumns.Distinct().ToList(); } - public DataTable CastObjectsToTableView(List psObjects) - { - List objectFormats = psObjects.Select(GetFormatViewDefinitionForObject).ToList(); + return new DataTableRow(valuePairs, objectIndex); + } - var dataTableColumns = GetDataColumnsForObject(psObjects); + /// + /// Sets the data type on each column based on the values in the data rows. + /// + private static void SetTypesOnDataColumns(List dataTableRows, List dataTableColumns) + { + var dataRows = dataTableRows.Select(x => x.Values); + + foreach (var dataColumn in dataTableColumns) + dataColumn.StringType = typeof(decimal).FullName; - List dataTableRows = new List(); - for (var i = 0; i < objectFormats.Count; i++) + // If every value in a column could be a decimal, assume that it is supposed to be a decimal + foreach (var dataRow in dataRows) + { + foreach (var dataColumn in dataTableColumns) { - var dataTableRow = CastObjectToDataTableRow(psObjects[i], dataTableColumns, i); - dataTableRows.Add(dataTableRow); + if (dataRow[dataColumn.ToString()] is not DecimalValue) + dataColumn.StringType = typeof(string).FullName; } + } + } - SetTypesOnDataColumns(dataTableRows, dataTableColumns); - - return new DataTable(dataTableColumns, dataTableRows); + /// + /// Converts a list of PowerShell objects into a data table structure suitable for display in the grid view. + /// + /// The list of PowerShell objects to convert. + /// If true, includes all properties instead of just the default display properties. + public static DataTable CastObjectsToTableView(List psObjects, bool allProperties = false) + { + if (psObjects.Count == 0) + { + return new DataTable([], []); } + // Get the columns using format view definitions + var typeGetter = new TypeGetter(); + List dataTableColumns = typeGetter.GetDataColumnsForObject(psObjects, allProperties).ToList(); - //Types that are condisidered primitives to PowerShell but not C# - private readonly static List additionalPrimitiveTypes = new List { "System.String", - "System.Decimal", - "System.IntPtr", - "System.Security.SecureString", - "System.Numerics.BigInteger" - }; - private static bool PSObjectIsPrimitive(PSObject ps) + // Convert each object to a row + var dataTableRows = new List(); + for (var i = 0; i < psObjects.Count; i++) { - var psBaseType = ps.BaseObject.GetType(); - - return psBaseType.IsPrimitive || psBaseType.IsEnum || additionalPrimitiveTypes.Contains(psBaseType.FullName); + var dataTableRow = CastObjectToDataTableRow(psObjects[i], dataTableColumns, i); + dataTableRows.Add(dataTableRow); } + + SetTypesOnDataColumns(dataTableRows, dataTableColumns); + + return new DataTable(dataTableColumns, dataTableRows); } -} +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs b/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs index 84959f5..0db0656 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/ApplicationData.cs @@ -1,24 +1,63 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System; using System.Collections.Generic; -namespace OutGridView.Models +namespace Microsoft.PowerShell.OutGridView.Models; + +/// +/// Represents the application configuration and state data for the Out-ConsoleGridView component. +/// +public class ApplicationData { - public class ApplicationData - { - public string Title { get; set; } - public OutputModeOption OutputMode { get; set; } - public bool PassThru { get; set; } - public string Filter { get; set; } - public bool MinUI { get; set; } - public DataTable DataTable { get; set; } - - public bool UseNetDriver { get; set; } - public bool Verbose { get; set; } - public bool Debug { get; set; } - - public string ModuleVersion { get; set; } - } -} + /// + /// Gets or sets the PowerShell objects to display. + /// + public List? PSObjects { get; set; } + + /// + /// Gets or sets the output mode that determines how items can be selected and returned. + /// + public OutputModeOption OutputMode { get; set; } + + /// + /// Gets or sets the title displayed in the Out-GridView window. + /// + public string? Title { get; set; } + + /// + /// Gets or sets the filter text to apply to the data. + /// + public string? Filter { get; set; } + + /// + /// Gets or sets a value indicating whether to use minimal UI mode. + /// + public bool MinUI { get; set; } + + /// + /// Gets or sets the driver to use for rendering. + /// + public string? ForceDriver { get; set; } + + /// + /// Gets or sets a value indicating whether all properties should be displayed. If false, only default display + /// properties are shown. + /// + public bool AllProperties { get; set; } + + /// + /// Gets or sets a value indicating whether verbose output is enabled. + /// + public bool Verbose { get; set; } + + /// + /// Gets or sets a value indicating whether debug output is enabled. + /// + public bool Debug { get; set; } + + /// + /// Gets or sets the version of the module. + /// + public string? ModuleVersion { get; set; } +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.OutGridView.Models/DataTable.cs b/src/Microsoft.PowerShell.OutGridView.Models/DataTable.cs index 950a7e5..3260700 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/DataTable.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/DataTable.cs @@ -2,18 +2,32 @@ // Licensed under the MIT License. using System.Collections.Generic; -using System.Collections.ObjectModel; -namespace OutGridView.Models + +namespace Microsoft.PowerShell.OutGridView.Models; + +/// +/// Represents a data table containing rows and columns for display in the grid view. +/// +public class DataTable { - public class DataTable - { - public List Data { get; set; } - public List DataColumns { get; set; } - public DataTable(List columns, List data) - { - DataColumns = columns; + /// + /// Gets or sets the list of data rows in the table. + /// + public List Data { get; set; } - Data = data; - } + /// + /// Gets or sets the list of column definitions for the table. + /// + public List DataColumns { get; set; } + + /// + /// Initializes a new instance of the class with the specified columns and data. + /// + /// The list of column definitions for the table. + /// The list of data rows for the table. + public DataTable(List columns, List data) + { + DataColumns = columns; + Data = data; } -} +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.OutGridView.Models/DataTableColumn.cs b/src/Microsoft.PowerShell.OutGridView.Models/DataTableColumn.cs index 0390dc4..a2e70a3 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/DataTableColumn.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/DataTableColumn.cs @@ -2,39 +2,114 @@ // Licensed under the MIT License. using System; -using Newtonsoft.Json; using System.Text; +using Newtonsoft.Json; + +// TODO: switch to System.Text.Json -namespace OutGridView.Models +namespace Microsoft.PowerShell.OutGridView.Models; + +/// +/// Represents a column in a data table with metadata about its label, type, and property accessor. +/// +/// The display label for the column. +/// The script accessor used to retrieve the property value. +public class DataTableColumn(string label, string propertyScriptAccessor) { - public class DataTableColumn + /// + /// Gets the runtime type of the column based on the property. + /// + [JsonIgnore] + public Type? Type => System.Type.GetType(StringType!); + + /// + /// Gets the display label for the column. + /// + public string Label { get; } = label; + + /// + /// Gets or sets the serializable string representation of the column's type. + /// + public string? StringType { get; set; } + + /// + /// Gets or sets the format string used to format values in this column. + /// This follows PowerShell/. NET composite formatting conventions (e.g., "N0" for numbers, "G" for DateTime). + /// + public string? FormatString { get; set; } + + /// + /// Gets the script accessor used to retrieve the property value for this column. + /// + public string PropertyScriptAccessor { get; } = propertyScriptAccessor; + + /// + /// Formats a value according to this column's format specification. + /// + /// The value to format. + /// A formatted string representation of the value. + public string FormatValue(object? value) { - [JsonIgnore] - public Type Type => Type.GetType(StringType); - public string Label { get; set; } - //Serializable Version of Type - public string StringType { get; set; } - public string PropertyScriptAccessor { get; set; } - public DataTableColumn(string label, string propertyScriptAccessor) - { - Label = label; - PropertyScriptAccessor = propertyScriptAccessor; - } + if (value == null) return string.Empty; - //Distinct column defined by Label, Prop Accessor - public override bool Equals(object obj) + // If we have a format string, try to use it + if (!string.IsNullOrEmpty(FormatString) && value is IFormattable formattable) { - DataTableColumn b = obj as DataTableColumn; - return b.Label == Label && b.PropertyScriptAccessor == PropertyScriptAccessor; + try + { + return formattable.ToString(FormatString, System.Globalization.CultureInfo.CurrentCulture); + } + catch + { + // Fall through to default formatting if format string is invalid + } } - public override int GetHashCode() + + // If FormatString is explicitly null, use simple ToString for most types + // (this prevents unwanted formatting of identifier integers like ProcessId) + if (FormatString == null) { - return Label.GetHashCode() + PropertyScriptAccessor.GetHashCode(); + return value.ToString() ?? string.Empty; } - public override string ToString() + + // Default formatting based on type (only when FormatString is empty but not null) + return value switch { - //Needs to be encoded to embed safely in xaml - return Convert.ToBase64String(Encoding.UTF8.GetBytes(Label + PropertyScriptAccessor)); - } + DateTime dt => dt.ToString("G", System.Globalization.CultureInfo.CurrentCulture), + decimal d => d.ToString("N0", System.Globalization.CultureInfo.CurrentCulture), + double db => db.ToString("N2", System.Globalization.CultureInfo.CurrentCulture), + float f => f.ToString("N2", System.Globalization.CultureInfo.CurrentCulture), + int or long or short or byte => string.Format(System.Globalization.CultureInfo.CurrentCulture, "{0:N0}", value), + _ => value.ToString() ?? string.Empty + }; } -} + + /// + /// Determines whether the specified object is equal to the current column. + /// Two columns are considered equal if they have the same label and property script accessor. + /// + /// The object to compare with the current column. + /// + /// if the specified object is equal to the current column; otherwise, + /// . + /// + public override bool Equals(object? obj) + { + var b = obj as DataTableColumn; + return b?.Label == Label && b.PropertyScriptAccessor == PropertyScriptAccessor; + } + + /// + /// Returns the hash code for this column based on its label and property script accessor. + /// + /// A hash code for the current column. + public override int GetHashCode() => Label.GetHashCode() + PropertyScriptAccessor.GetHashCode(); + + /// + /// Returns a Base64-encoded string representation of the column for safe embedding in XAML. + /// + /// A Base64-encoded string containing the label and property script accessor. + public override string ToString() => + // Needs to be encoded to embed safely in XAML + Convert.ToBase64String(Encoding.UTF8.GetBytes(Label + PropertyScriptAccessor)); +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.OutGridView.Models/DataTableRow.cs b/src/Microsoft.PowerShell.OutGridView.Models/DataTableRow.cs index 3888691..5eb958d 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/DataTableRow.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/DataTableRow.cs @@ -4,44 +4,118 @@ using System; using System.Collections.Generic; -namespace OutGridView.Models +namespace Microsoft.PowerShell.OutGridView.Models; + +/// +/// Represents a value that can be displayed and compared in a data table. +/// +public interface IValue : IComparable { - public interface IValue : IComparable - { - string DisplayValue { get; set; } - } - public class DecimalValue : IValue - { - public string DisplayValue { get; set; } - public decimal SortValue { get; set; } - - public int CompareTo(object obj) - { - DecimalValue otherDecimalValue = obj as DecimalValue; - if (otherDecimalValue == null) return 1; - return Decimal.Compare(SortValue, otherDecimalValue.SortValue); - } - } - public class StringValue : IValue - { - public string DisplayValue { get; set; } - public int CompareTo(object obj) - { - StringValue otherStringValue = obj as StringValue; - if (otherStringValue == null) return 1; - return DisplayValue.CompareTo(otherStringValue.DisplayValue); - } - } - public class DataTableRow + /// + /// Gets or sets the string representation of the value for display purposes. + /// + string DisplayValue { get; set; } + + /// + /// Gets the original object value before formatting. + /// + object? OriginalValue { get; } +} + +/// +/// Represents a decimal value in a data table with support for numeric sorting. +/// +public class DecimalValue : IValue +{ + /// + /// Gets or sets the string representation of the decimal value for display purposes. + /// + public required string DisplayValue { get; set; } + + /// + /// Gets or sets the decimal value used for sorting. + /// + public decimal SortValue { get; set; } + + /// + /// Gets the original decimal value. + /// + public object? OriginalValue => SortValue; + + /// + /// Compares the current instance with another object of the same type. + /// + /// An object to compare with this instance. + /// + /// A value that indicates the relative order of the objects being compared. + /// Less than zero if this instance precedes , + /// zero if they are equal, or greater than zero if this instance follows . + /// Returns 1 if is not a . + /// + public int CompareTo(object? obj) => obj is not DecimalValue otherDecimalValue + ? 1 + : decimal.Compare(SortValue, otherDecimalValue.SortValue); +} + +/// +/// Represents a string value in a data table with support for string sorting. +/// +public class StringValue : IValue +{ + /// + /// Gets or sets the string value for display and sorting purposes. + /// + public required string DisplayValue { get; set; } + + /// + /// Gets or sets the original object value before conversion to string. + /// + public object? RawValue { get; set; } + + /// + /// Gets the original object value. + /// + public object? OriginalValue => RawValue ?? DisplayValue; + + /// + /// Compares the current instance with another object of the same type. + /// + /// An object to compare with this instance. + /// + /// A value that indicates the relative order of the objects being compared. + /// Less than zero if this instance precedes , + /// zero if they are equal, or greater than zero if this instance follows . + /// Returns 1 if is not a . + /// + public int CompareTo(object? obj) => obj is not StringValue otherStringValue + ? 1 + : string.Compare(DisplayValue, otherStringValue.DisplayValue, StringComparison.Ordinal); +} + +/// +/// Represents a single row in a data table with values mapped to column identifiers. +/// +public class DataTableRow +{ + /// + /// Gets or sets the dictionary of values for this row, keyed by the column identifier. + /// The key is the data column hash code serialized as a string for JSON compatibility. + /// + public Dictionary Values { get; set; } + + /// + /// Gets or sets the original index of the object in the source collection before any transformations. + /// + public int OriginalObjectIndex { get; set; } + + /// + /// Initializes a new instance of the class with the specified values and original index. + /// + /// The dictionary of values for this row, keyed by column identifier. + /// The original index of the object in the source collection. + public DataTableRow(Dictionary data, int originalObjectIndex) { - //key is datacolumn hash code - //have to do it this way because JSON can't serialize objects as keys - public Dictionary Values { get; set; } - public int OriginalObjectIndex { get; set; } - public DataTableRow(Dictionary data, int originalObjectIndex) - { - Values = data; - OriginalObjectIndex = originalObjectIndex; - } + Values = data; + OriginalObjectIndex = originalObjectIndex; } -} +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.OutGridView.Models/Microsoft.PowerShell.OutGridView.Models.csproj b/src/Microsoft.PowerShell.OutGridView.Models/Microsoft.PowerShell.OutGridView.Models.csproj index a4f10f5..f532b00 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/Microsoft.PowerShell.OutGridView.Models.csproj +++ b/src/Microsoft.PowerShell.OutGridView.Models/Microsoft.PowerShell.OutGridView.Models.csproj @@ -1,9 +1,15 @@ - net8.0 + net10.0 + latest + enable + + + + diff --git a/src/Microsoft.PowerShell.OutGridView.Models/OutputModeOptions.cs b/src/Microsoft.PowerShell.OutGridView.Models/OutputModeOptions.cs index 3170c98..bc499b8 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/OutputModeOptions.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/OutputModeOptions.cs @@ -1,23 +1,24 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -namespace OutGridView.Models +namespace Microsoft.PowerShell.OutGridView.Models; + +public enum OutputModeOption { - public enum OutputModeOption - { - /// - /// None is the default and it means OK and Cancel will not be present - /// and no objects will be written to the pipeline. - /// The selectionMode of the actual list will still be multiple. - /// - None, - /// - /// Allow selection of one single item to be written to the pipeline. - /// - Single, - /// - ///Allow select of multiple items to be written to the pipeline. - /// - Multiple - } -} + /// + /// None is the default, and it means OK and Cancel will not be present + /// and no objects will be written to the pipeline. + /// The selectionMode of the actual list will still be multiple. + /// + None, + + /// + /// Allow selection of one single item to be written to the pipeline. + /// + Single, + + /// + /// Allow select of multiple items to be written to the pipeline. + /// + Multiple +} \ No newline at end of file diff --git a/src/Microsoft.PowerShell.OutGridView.Models/Serializers.cs b/src/Microsoft.PowerShell.OutGridView.Models/Serializers.cs index 6165c23..f5f8a53 100644 --- a/src/Microsoft.PowerShell.OutGridView.Models/Serializers.cs +++ b/src/Microsoft.PowerShell.OutGridView.Models/Serializers.cs @@ -1,32 +1,33 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using Newtonsoft.Json; using System; using System.Text; -using System.Collections.Generic; -//TODO: swich to JSON.NET +using Newtonsoft.Json; + +// TODO: switch to JSON.NET +// BUGBUG: This appears to be unused code. Consider removing it. -namespace OutGridView.Models +namespace Microsoft.PowerShell.OutGridView.Models { public class Serializers { - private readonly static JsonSerializerSettings jsonSerializerSettings = new JsonSerializerSettings() + private static readonly JsonSerializerSettings JSON_SERIALIZER_SETTINGS = new JsonSerializerSettings() { TypeNameHandling = TypeNameHandling.All }; public static string ObjectToJson(T obj) { - var jsonString = JsonConvert.SerializeObject(obj, jsonSerializerSettings); + var jsonString = JsonConvert.SerializeObject(obj, JSON_SERIALIZER_SETTINGS); return ToBase64String(jsonString); } - public static T ObjectFromJson(string base64Json) + public static T? ObjectFromJson(string base64Json) { var jsonString = FromBase64String(base64Json); - return JsonConvert.DeserializeObject(jsonString, jsonSerializerSettings); + return JsonConvert.DeserializeObject(jsonString, JSON_SERIALIZER_SETTINGS); } diff --git a/test/Microsoft.PowerShell.ConsoleGuiTools.Tests.ps1 b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests.ps1 new file mode 100644 index 0000000..e334055 --- /dev/null +++ b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests.ps1 @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +Describe 'Microsoft.PowerShell.ConsoleGuiTools Module' { + + BeforeAll { + $modulePath = Join-Path $PSScriptRoot '..' 'module' 'Microsoft.PowerShell.ConsoleGuiTools.psd1' + if (-not (Test-Path $modulePath)) { + $modulePath = Join-Path $PSScriptRoot '..' 'src' 'Microsoft.PowerShell.ConsoleGuiTools' 'publish' 'Microsoft.PowerShell.ConsoleGuiTools.psd1' + } + if (Test-Path $modulePath) { + Import-Module $modulePath -Force -ErrorAction Stop + } else { + throw "Module not found. Build the project first with 'Invoke-Build Build'." + } + } + + Context 'Module loads correctly' { + It 'Should import without errors' { + $module = Get-Module -Name Microsoft.PowerShell.ConsoleGuiTools + $module | Should -Not -BeNullOrEmpty + } + + It 'Should export Out-ConsoleGridView command' { + $cmd = Get-Command -Name Out-ConsoleGridView -Module Microsoft.PowerShell.ConsoleGuiTools -ErrorAction SilentlyContinue + $cmd | Should -Not -BeNullOrEmpty + } + + It 'Should export Show-ObjectTree command' { + $cmd = Get-Command -Name Show-ObjectTree -Module Microsoft.PowerShell.ConsoleGuiTools -ErrorAction SilentlyContinue + $cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'Aliases' { + It 'Should register ocgv alias for Out-ConsoleGridView' { + $alias = Get-Alias -Name ocgv -ErrorAction SilentlyContinue + $alias | Should -Not -BeNullOrEmpty + $alias.Definition | Should -Be 'Out-ConsoleGridView' + } + + It 'Should register shot alias for Show-ObjectTree' { + $alias = Get-Alias -Name shot -ErrorAction SilentlyContinue + $alias | Should -Not -BeNullOrEmpty + $alias.Definition | Should -Be 'Show-ObjectTree' + } + } + + Context 'Out-ConsoleGridView parameters' { + It 'Should have InputObject parameter' { + $cmd = Get-Command Out-ConsoleGridView + $cmd.Parameters.Keys | Should -Contain 'InputObject' + } + + It 'Should have Title parameter' { + $cmd = Get-Command Out-ConsoleGridView + $cmd.Parameters.Keys | Should -Contain 'Title' + } + + It 'Should have OutputMode parameter' { + $cmd = Get-Command Out-ConsoleGridView + $cmd.Parameters.Keys | Should -Contain 'OutputMode' + } + + It 'Should have Filter parameter' { + $cmd = Get-Command Out-ConsoleGridView + $cmd.Parameters.Keys | Should -Contain 'Filter' + } + + It 'Should have MinUI parameter' { + $cmd = Get-Command Out-ConsoleGridView + $cmd.Parameters.Keys | Should -Contain 'MinUI' + } + } + + Context 'Show-ObjectTree parameters' { + It 'Should have InputObject parameter' { + $cmd = Get-Command Show-ObjectTree + $cmd.Parameters.Keys | Should -Contain 'InputObject' + } + + It 'Should have Title parameter' { + $cmd = Get-Command Show-ObjectTree + $cmd.Parameters.Keys | Should -Contain 'Title' + } + + It 'Should have Filter parameter' { + $cmd = Get-Command Show-ObjectTree + $cmd.Parameters.Keys | Should -Contain 'Filter' + } + + It 'Should have MinUI parameter' { + $cmd = Get-Command Show-ObjectTree + $cmd.Parameters.Keys | Should -Contain 'MinUI' + } + } + + AfterAll { + Remove-Module -Name Microsoft.PowerShell.ConsoleGuiTools -Force -ErrorAction SilentlyContinue + } +} diff --git a/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/DataTableColumnTests.cs b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/DataTableColumnTests.cs new file mode 100644 index 0000000..381f11c --- /dev/null +++ b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/DataTableColumnTests.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Globalization; +using Microsoft.PowerShell.OutGridView.Models; +using Xunit; + +namespace Microsoft.PowerShell.ConsoleGuiTools.Tests; + +public class DataTableColumnTests +{ + [Fact] + public void FormatValue_NullValue_ReturnsEmptyString() + { + var column = new DataTableColumn("Test", "$_.Test"); + Assert.Equal(string.Empty, column.FormatValue(null)); + } + + [Fact] + public void FormatValue_WithFormatString_UsesFormatString() + { + var column = new DataTableColumn("Test", "$_.Test") { FormatString = "N2" }; + var result = column.FormatValue(123.456m); + // Should format as decimal with 2 decimal places + Assert.Contains("123", result); + } + + [Fact] + public void FormatValue_NullFormatString_UsesToString() + { + // When FormatString is explicitly null, use plain ToString (no default formatting) + var column = new DataTableColumn("Id", "$_.Id") { FormatString = null }; + var result = column.FormatValue(1234); + // Should NOT have thousands separator — plain ToString + Assert.Equal("1234", result); + } + + [Fact] + public void FormatValue_EmptyFormatString_UsesDefaultTypeFormatting() + { + // When FormatString is empty string (not null), use type-based defaults + var column = new DataTableColumn("Size", "$_.Size") { FormatString = "" }; + var result = column.FormatValue(1234); + // int with empty FormatString should use N0 formatting (thousands separator) + var expected = 1234.ToString("N0", CultureInfo.CurrentCulture); + Assert.Equal(expected, result); + } + + [Fact] + public void FormatValue_DateTime_WithEmptyFormatString_UsesGeneralFormat() + { + var column = new DataTableColumn("Date", "$_.Date") { FormatString = "" }; + var dt = new DateTime(2024, 6, 15, 14, 30, 0); + var result = column.FormatValue(dt); + var expected = dt.ToString("G", CultureInfo.CurrentCulture); + Assert.Equal(expected, result); + } + + [Fact] + public void FormatValue_Decimal_WithEmptyFormatString_UsesN0() + { + var column = new DataTableColumn("Amount", "$_.Amount") { FormatString = "" }; + var result = column.FormatValue(1234567.89m); + var expected = 1234567.89m.ToString("N0", CultureInfo.CurrentCulture); + Assert.Equal(expected, result); + } + + [Fact] + public void FormatValue_Double_WithEmptyFormatString_UsesN2() + { + var column = new DataTableColumn("Value", "$_.Value") { FormatString = "" }; + var result = column.FormatValue(3.14159); + var expected = 3.14159.ToString("N2", CultureInfo.CurrentCulture); + Assert.Equal(expected, result); + } + + [Fact] + public void FormatValue_String_ReturnsStringValue() + { + var column = new DataTableColumn("Name", "$_.Name"); + Assert.Equal("hello", column.FormatValue("hello")); + } + + [Fact] + public void FormatValue_InvalidFormatString_FallsThrough() + { + var column = new DataTableColumn("Test", "$_.Test") { FormatString = "ZZZZ_INVALID" }; + // An invalid format string should not throw — it should fall through + var result = column.FormatValue(42); + Assert.NotNull(result); + } + + [Fact] + public void Equals_SameLabelAndAccessor_ReturnsTrue() + { + var a = new DataTableColumn("Name", "$_.Name"); + var b = new DataTableColumn("Name", "$_.Name"); + Assert.True(a.Equals(b)); + } + + [Fact] + public void Equals_DifferentLabel_ReturnsFalse() + { + var a = new DataTableColumn("Name", "$_.Name"); + var b = new DataTableColumn("Id", "$_.Name"); + Assert.False(a.Equals(b)); + } + + [Fact] + public void Equals_DifferentAccessor_ReturnsFalse() + { + var a = new DataTableColumn("Name", "$_.Name"); + var b = new DataTableColumn("Name", "$_.Id"); + Assert.False(a.Equals(b)); + } + + [Fact] + public void Equals_Null_ReturnsFalse() + { + var a = new DataTableColumn("Name", "$_.Name"); + Assert.False(a.Equals(null)); + } + + [Fact] + public void GetHashCode_EqualColumns_SameHashCode() + { + var a = new DataTableColumn("Name", "$_.Name"); + var b = new DataTableColumn("Name", "$_.Name"); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void ToString_ReturnsBase64EncodedString() + { + var column = new DataTableColumn("Name", "$_.Name"); + var result = column.ToString(); + // Should decode to "Name" + "$_.Name" + var decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(result)); + Assert.Equal("Name$_.Name", decoded); + } + + [Fact] + public void Type_ValidStringType_ReturnsCorrectType() + { + var column = new DataTableColumn("Test", "$_.Test") + { + StringType = typeof(decimal).FullName + }; + Assert.Equal(typeof(decimal), column.Type); + } + + [Fact] + public void Type_NullStringType_ThrowsOnAccess() + { + var column = new DataTableColumn("Test", "$_.Test"); + // StringType is null by default; Type.GetType(null) throws ArgumentNullException + Assert.Throws(() => column.Type); + } +} diff --git a/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/DataTableRowTests.cs b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/DataTableRowTests.cs new file mode 100644 index 0000000..12d975f --- /dev/null +++ b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/DataTableRowTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Microsoft.PowerShell.OutGridView.Models; +using Xunit; + +namespace Microsoft.PowerShell.ConsoleGuiTools.Tests; + +public class DataTableRowTests +{ + [Fact] + public void DecimalValue_CompareTo_LessThan() + { + var a = new DecimalValue { DisplayValue = "1", SortValue = 1m }; + var b = new DecimalValue { DisplayValue = "2", SortValue = 2m }; + Assert.True(a.CompareTo(b) < 0); + } + + [Fact] + public void DecimalValue_CompareTo_GreaterThan() + { + var a = new DecimalValue { DisplayValue = "2", SortValue = 2m }; + var b = new DecimalValue { DisplayValue = "1", SortValue = 1m }; + Assert.True(a.CompareTo(b) > 0); + } + + [Fact] + public void DecimalValue_CompareTo_Equal() + { + var a = new DecimalValue { DisplayValue = "1", SortValue = 1m }; + var b = new DecimalValue { DisplayValue = "1", SortValue = 1m }; + Assert.Equal(0, a.CompareTo(b)); + } + + [Fact] + public void DecimalValue_CompareTo_NonDecimalValue_Returns1() + { + var a = new DecimalValue { DisplayValue = "1", SortValue = 1m }; + Assert.Equal(1, a.CompareTo("not a decimal value")); + } + + [Fact] + public void DecimalValue_CompareTo_Null_Returns1() + { + var a = new DecimalValue { DisplayValue = "1", SortValue = 1m }; + Assert.Equal(1, a.CompareTo(null)); + } + + [Fact] + public void DecimalValue_OriginalValue_ReturnsSortValue() + { + var v = new DecimalValue { DisplayValue = "42", SortValue = 42m }; + Assert.Equal(42m, v.OriginalValue); + } + + [Fact] + public void StringValue_CompareTo_LessThan() + { + var a = new StringValue { DisplayValue = "apple" }; + var b = new StringValue { DisplayValue = "banana" }; + Assert.True(a.CompareTo(b) < 0); + } + + [Fact] + public void StringValue_CompareTo_GreaterThan() + { + var a = new StringValue { DisplayValue = "banana" }; + var b = new StringValue { DisplayValue = "apple" }; + Assert.True(a.CompareTo(b) > 0); + } + + [Fact] + public void StringValue_CompareTo_Equal() + { + var a = new StringValue { DisplayValue = "same" }; + var b = new StringValue { DisplayValue = "same" }; + Assert.Equal(0, a.CompareTo(b)); + } + + [Fact] + public void StringValue_CompareTo_NonStringValue_Returns1() + { + var a = new StringValue { DisplayValue = "test" }; + Assert.Equal(1, a.CompareTo(42)); + } + + [Fact] + public void StringValue_OriginalValue_WithRawValue_ReturnsRawValue() + { + var raw = new object(); + var v = new StringValue { DisplayValue = "display", RawValue = raw }; + Assert.Same(raw, v.OriginalValue); + } + + [Fact] + public void StringValue_OriginalValue_WithoutRawValue_ReturnsDisplayValue() + { + var v = new StringValue { DisplayValue = "display" }; + Assert.Equal("display", v.OriginalValue); + } + + [Fact] + public void DataTableRow_Constructor_SetsProperties() + { + var values = new Dictionary + { + ["col1"] = new StringValue { DisplayValue = "value1" } + }; + var row = new DataTableRow(values, 5); + + Assert.Same(values, row.Values); + Assert.Equal(5, row.OriginalObjectIndex); + } + + [Fact] + public void DataTable_Constructor_SetsProperties() + { + var columns = new List + { + new DataTableColumn("Name", "$_.Name") + }; + var rows = new List(); + var table = new DataTable(columns, rows); + + Assert.Same(columns, table.DataColumns); + Assert.Same(rows, table.Data); + } +} diff --git a/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/GridViewHelpersTests.cs b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/GridViewHelpersTests.cs new file mode 100644 index 0000000..3eaa1ea --- /dev/null +++ b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/GridViewHelpersTests.cs @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.PowerShell.ConsoleGuiTools.Tests; + +public class GridViewHelpersTests +{ + #region FilterData + + [Fact] + public void FilterData_EmptyFilter_ReturnsOriginalList() + { + var rows = new List + { + new() { DisplayString = "apple", OriginalIndex = 0 }, + new() { DisplayString = "banana", OriginalIndex = 1 } + }; + + var result = GridViewHelpers.FilterData(rows, ""); + Assert.Same(rows, result); + } + + [Fact] + public void FilterData_NullFilter_ReturnsOriginalList() + { + var rows = new List + { + new() { DisplayString = "apple", OriginalIndex = 0 } + }; + + var result = GridViewHelpers.FilterData(rows, null!); + Assert.Same(rows, result); + } + + [Fact] + public void FilterData_MatchingFilter_ReturnsMatchingRows() + { + var rows = new List + { + new() { DisplayString = "apple pie", OriginalIndex = 0 }, + new() { DisplayString = "banana split", OriginalIndex = 1 }, + new() { DisplayString = "apple sauce", OriginalIndex = 2 } + }; + + var result = GridViewHelpers.FilterData(rows, "apple"); + Assert.Equal(2, result.Count); + Assert.Equal("apple pie", result[0].DisplayString); + Assert.Equal("apple sauce", result[1].DisplayString); + } + + [Fact] + public void FilterData_CaseInsensitive() + { + var rows = new List + { + new() { DisplayString = "APPLE", OriginalIndex = 0 }, + new() { DisplayString = "banana", OriginalIndex = 1 } + }; + + var result = GridViewHelpers.FilterData(rows, "apple"); + Assert.Single(result); + Assert.Equal("APPLE", result[0].DisplayString); + } + + [Fact] + public void FilterData_MarkedItemsAlwaysIncluded() + { + var rows = new List + { + new() { DisplayString = "apple", OriginalIndex = 0, IsMarked = true }, + new() { DisplayString = "banana", OriginalIndex = 1, IsMarked = false }, + new() { DisplayString = "cherry", OriginalIndex = 2, IsMarked = true } + }; + + // Filter for "banana" but marked items should still appear first + var result = GridViewHelpers.FilterData(rows, "banana"); + Assert.Equal(3, result.Count); + Assert.Equal("apple", result[0].DisplayString); // marked, first + Assert.Equal("cherry", result[1].DisplayString); // marked, first + Assert.Equal("banana", result[2].DisplayString); // matched filter + } + + [Fact] + public void FilterData_NoMatch_ReturnsOnlyMarkedItems() + { + var rows = new List + { + new() { DisplayString = "apple", OriginalIndex = 0, IsMarked = true }, + new() { DisplayString = "banana", OriginalIndex = 1, IsMarked = false } + }; + + var result = GridViewHelpers.FilterData(rows, "zzz_no_match"); + Assert.Single(result); + Assert.Equal("apple", result[0].DisplayString); + } + + [Fact] + public void FilterData_RegexPattern() + { + var rows = new List + { + new() { DisplayString = "item123", OriginalIndex = 0 }, + new() { DisplayString = "item456", OriginalIndex = 1 }, + new() { DisplayString = "other", OriginalIndex = 2 } + }; + + var result = GridViewHelpers.FilterData(rows, @"item\d+"); + Assert.Equal(2, result.Count); + } + + #endregion + + #region GetPaddedString + + [Fact] + public void GetPaddedString_NullColumnWidths_ReturnsEmptyString() + { + var result = GridViewHelpers.GetPaddedString(new List { "test" }, 0, null); + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetPaddedString_NullStrings_ReturnsOffset() + { + var result = GridViewHelpers.GetPaddedString(null, 3, new[] { 10 }); + Assert.Equal(" ", result); + } + + [Fact] + public void GetPaddedString_StringFitsInColumn_PadsRight() + { + var result = GridViewHelpers.GetPaddedString( + new List { "hi" }, 0, new[] { 10 }); + Assert.Equal("hi ", result); + } + + [Fact] + public void GetPaddedString_StringExceedsColumn_Truncated_WithEllipsis() + { + var result = GridViewHelpers.GetPaddedString( + new List { "Hello World!" }, 0, new[] { 8 }); + Assert.Equal("Hello...", result); + } + + [Fact] + public void GetPaddedString_VerySmallColumn_TruncatesWithoutEllipsis() + { + var result = GridViewHelpers.GetPaddedString( + new List { "Hello" }, 0, new[] { 3 }); + Assert.Equal("Hel", result); + } + + [Fact] + public void GetPaddedString_ZeroWidthColumn_SkipsContent() + { + var result = GridViewHelpers.GetPaddedString( + new List { "Hello" }, 0, new[] { 0 }); + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetPaddedString_MultipleColumns_SeparatedBySpace() + { + var result = GridViewHelpers.GetPaddedString( + new List { "A", "B" }, 0, new[] { 5, 5 }); + Assert.Equal("A B ", result); + } + + [Fact] + public void GetPaddedString_WithOffset_PadsLeft() + { + var result = GridViewHelpers.GetPaddedString( + new List { "Hi" }, 4, new[] { 5 }); + Assert.Equal(" Hi ", result); + } + + [Fact] + public void GetPaddedString_NewlinesEncoded() + { + var result = GridViewHelpers.GetPaddedString( + new List { "line1\nline2" }, 0, new[] { 20 }); + Assert.Contains("`n", result); + Assert.DoesNotContain("\n", result); + } + + [Fact] + public void GetPaddedString_CarriageReturnsEncoded() + { + var result = GridViewHelpers.GetPaddedString( + new List { "line1\rline2" }, 0, new[] { 20 }); + Assert.Contains("`r", result); + Assert.DoesNotContain("\r", result); + } + + #endregion +} diff --git a/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/Microsoft.PowerShell.ConsoleGuiTools.Tests.csproj b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/Microsoft.PowerShell.ConsoleGuiTools.Tests.csproj new file mode 100644 index 0000000..64613f3 --- /dev/null +++ b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/Microsoft.PowerShell.ConsoleGuiTools.Tests.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + preview + enable + false + true + + + + + + + + + + + + + + diff --git a/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/OutGridViewWindowIntegrationTests.cs b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/OutGridViewWindowIntegrationTests.cs new file mode 100644 index 0000000..6e2006f --- /dev/null +++ b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/OutGridViewWindowIntegrationTests.cs @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Management.Automation; +using Microsoft.PowerShell.OutGridView.Models; +using Terminal.Gui.App; +using Terminal.Gui.Input; +using Terminal.Gui.Time; +using Xunit; + +namespace Microsoft.PowerShell.ConsoleGuiTools.Tests; + +/// +/// Integration tests for OutGridViewWindow that exercise the full Terminal.Gui lifecycle +/// using the headless ANSI driver and VirtualTimeProvider. +/// +public class OutGridViewWindowIntegrationTests +{ + #region Helpers + + private static PSObject CreatePSObject(string name, int id) + { + var obj = new PSObject(); + obj.Properties.Add(new PSNoteProperty("Name", name)); + obj.Properties.Add(new PSNoteProperty("Id", id)); + return obj; + } + + private static ApplicationData CreateApplicationData( + OutputModeOption outputMode = OutputModeOption.Multiple, + bool minUI = false, + string? filter = null) + { + var psObjects = new List + { + CreatePSObject("Process1", 1), + CreatePSObject("Process2", 2), + CreatePSObject("Process3", 3) + }; + + return new ApplicationData + { + PSObjects = psObjects, + Title = "Test Grid", + OutputMode = outputMode, + Filter = filter, + MinUI = minUI, + ForceDriver = null + }; + } + + /// + /// Sets up Terminal.Gui in headless mode, creates and begins the window, + /// invokes the test action, then tears down. + /// + private static void RunWithWindow( + ApplicationData appData, + Action testAction) + { + Terminal.Gui.Configuration.ConfigurationManager.Enable( + Terminal.Gui.Configuration.ConfigLocations.All); + + using OutGridViewWindow window = new(appData); + using IApplication app = Application.Create(new VirtualTimeProvider()) + .Init(driverName: "ansi"); + + SessionToken? token = app.Begin(window); + app.LayoutAndDraw(forceRedraw: true); + + try + { + testAction(app, window); + } + finally + { + if (token is not null) + { + app.End(token); + } + } + } + + #endregion + + #region ESC / Close Tests + + [Fact] + public void Esc_ClosesWindow_ResultIsNull() + { + var appData = CreateApplicationData(); + + RunWithWindow(appData, (app, window) => + { + // Verify data is rendered + string screen = app.Driver!.ToString(); + Assert.Contains("Process1", screen); + Assert.Contains("Process2", screen); + Assert.Contains("Process3", screen); + + // Simulate ESC + app.Keyboard.RaiseKeyDownEvent(Key.Esc); + + // ESC triggers Close() which sets Result = null + Assert.Null(window.Result); + }); + } + + #endregion + + #region Accept / Selection Tests + + [Fact] + public void Enter_WithMarkedItems_ReturnsSelectedIndexes() + { + var appData = CreateApplicationData(outputMode: OutputModeOption.Multiple); + + RunWithWindow(appData, (app, window) => + { + // Mark first item + app.Keyboard.RaiseKeyDownEvent(Key.Space); + app.LayoutAndDraw(); + + // Move down and mark second item + app.Keyboard.RaiseKeyDownEvent(Key.CursorDown); + app.LayoutAndDraw(); + app.Keyboard.RaiseKeyDownEvent(Key.Space); + app.LayoutAndDraw(); + + // Accept + app.Keyboard.RaiseKeyDownEvent(Key.Enter); + + Assert.NotNull(window.Result); + var result = (HashSet)window.Result; + Assert.Contains(0, result); + Assert.Contains(1, result); + Assert.DoesNotContain(2, result); + }); + } + + [Fact] + public void Enter_WithNoMarkedItems_ReturnsEmptySet() + { + var appData = CreateApplicationData(outputMode: OutputModeOption.Multiple); + + RunWithWindow(appData, (app, window) => + { + app.Keyboard.RaiseKeyDownEvent(Key.Enter); + + Assert.NotNull(window.Result); + var result = (HashSet)window.Result; + Assert.Empty(result); + }); + } + + #endregion + + #region Rendering Tests + + [Fact] + public void Render_ShowsColumnHeadersAndData() + { + var appData = CreateApplicationData(); + + RunWithWindow(appData, (app, window) => + { + string screen = app.Driver!.ToString(); + + Assert.Contains("Name", screen); + Assert.Contains("Id", screen); + Assert.Contains("Process1", screen); + Assert.Contains("Process2", screen); + Assert.Contains("Process3", screen); + + app.Keyboard.RaiseKeyDownEvent(Key.Esc); + }); + } + + [Fact] + public void Render_WithFilter_ShowsOnlyMatchingRows() + { + var appData = CreateApplicationData(filter: "Process1"); + + RunWithWindow(appData, (app, window) => + { + string screen = app.Driver!.ToString(); + Assert.Contains("Process1", screen); + + app.Keyboard.RaiseKeyDownEvent(Key.Esc); + }); + } + + [Fact] + public void Render_MinUI_DoesNotShowFilterLabel() + { + var appData = CreateApplicationData(minUI: true); + + RunWithWindow(appData, (app, window) => + { + string screen = app.Driver!.ToString(); + + Assert.Contains("Process1", screen); + Assert.DoesNotContain("Filter:", screen); + + app.Keyboard.RaiseKeyDownEvent(Key.Esc); + }); + } + + #endregion + + #region OutputMode Tests + + [Fact] + public void OutputModeNone_EscClosesWithNullResult() + { + var appData = CreateApplicationData(outputMode: OutputModeOption.None); + + RunWithWindow(appData, (app, window) => + { + app.Keyboard.RaiseKeyDownEvent(Key.Esc); + Assert.Null(window.Result); + }); + } + + #endregion +} diff --git a/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/SerializersTests.cs b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/SerializersTests.cs new file mode 100644 index 0000000..c192307 --- /dev/null +++ b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/SerializersTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.PowerShell.OutGridView.Models; +using Xunit; + +namespace Microsoft.PowerShell.ConsoleGuiTools.Tests; + +public class SerializersTests +{ + [Fact] + public void RoundTrip_String() + { + var original = "Hello, World!"; + var json = Serializers.ObjectToJson(original); + var result = Serializers.ObjectFromJson(json); + Assert.Equal(original, result); + } + + [Fact] + public void RoundTrip_Integer() + { + var original = 42; + var json = Serializers.ObjectToJson(original); + var result = Serializers.ObjectFromJson(json); + Assert.Equal(original, result); + } + + [Fact] + public void RoundTrip_ComplexObject() + { + var original = new ApplicationData + { + Title = "Test", + OutputMode = OutputModeOption.Multiple, + Filter = "*.txt", + MinUI = true + }; + var json = Serializers.ObjectToJson(original); + var result = Serializers.ObjectFromJson(json); + + Assert.NotNull(result); + Assert.Equal("Test", result.Title); + Assert.Equal(OutputModeOption.Multiple, result.OutputMode); + Assert.Equal("*.txt", result.Filter); + Assert.True(result.MinUI); + } + + [Fact] + public void ObjectToJson_ReturnsBase64EncodedString() + { + var json = Serializers.ObjectToJson("test"); + // Should be valid base64 + var bytes = System.Convert.FromBase64String(json); + Assert.NotEmpty(bytes); + } + + [Fact] + public void RoundTrip_Null() + { + var json = Serializers.ObjectToJson(null); + var result = Serializers.ObjectFromJson(json); + Assert.Null(result); + } +} diff --git a/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/TypeGetterTests.cs b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/TypeGetterTests.cs new file mode 100644 index 0000000..6e9f5b3 --- /dev/null +++ b/test/Microsoft.PowerShell.ConsoleGuiTools.Tests/TypeGetterTests.cs @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Management.Automation; +using Microsoft.PowerShell.OutGridView.Models; +using Xunit; + +namespace Microsoft.PowerShell.ConsoleGuiTools.Tests; + +public class TypeGetterTests +{ + [Fact] + public void CastObjectsToTableView_EmptyList_ReturnsEmptyTable() + { + var result = TypeGetter.CastObjectsToTableView([]); + + Assert.Empty(result.DataColumns); + Assert.Empty(result.Data); + } + + [Fact] + public void CastObjectsToTableView_PrimitiveStrings_SingleColumn() + { + var objects = new List + { + new PSObject("hello"), + new PSObject("world") + }; + + var result = TypeGetter.CastObjectsToTableView(objects); + + Assert.Single(result.DataColumns); + Assert.Equal("String", result.DataColumns[0].Label); + Assert.Equal(2, result.Data.Count); + } + + [Fact] + public void CastObjectsToTableView_IntegersAsProperties_CreatesDecimalColumn() + { + var obj1 = new PSObject(); + obj1.Properties.Add(new PSNoteProperty("Id", 42)); + var obj2 = new PSObject(); + obj2.Properties.Add(new PSNoteProperty("Id", 99)); + + var result = TypeGetter.CastObjectsToTableView(new List { obj1, obj2 }); + + Assert.Single(result.DataColumns); + Assert.Equal("Id", result.DataColumns[0].Label); + Assert.Equal(2, result.Data.Count); + // All values numeric, so column type should be decimal + Assert.Equal(typeof(decimal).FullName, result.DataColumns[0].StringType); + } + + [Fact] + public void CastObjectsToTableView_ObjectWithProperties_CreatesColumns() + { + var obj = new PSObject(); + obj.Properties.Add(new PSNoteProperty("Name", "Test")); + obj.Properties.Add(new PSNoteProperty("Value", 42)); + + var result = TypeGetter.CastObjectsToTableView(new List { obj }); + + Assert.Equal(2, result.DataColumns.Count); + Assert.Contains(result.DataColumns, c => c.Label == "Name"); + Assert.Contains(result.DataColumns, c => c.Label == "Value"); + Assert.Single(result.Data); + } + + [Fact] + public void CastObjectToDataTableRow_SimpleProperty_ExtractsValue() + { + var obj = new PSObject(); + obj.Properties.Add(new PSNoteProperty("Name", "TestValue")); + + var columns = new List + { + new DataTableColumn("Name", "$_.\"Name\"") + }; + + var row = TypeGetter.CastObjectToDataTableRow(obj, columns, 0); + + Assert.Equal(0, row.OriginalObjectIndex); + var columnKey = columns[0].ToString(); + Assert.True(row.Values.ContainsKey(columnKey)); + Assert.Equal("TestValue", row.Values[columnKey].DisplayValue); + } + + [Fact] + public void CastObjectToDataTableRow_NumericProperty_CreatesDecimalValue() + { + var obj = new PSObject(); + obj.Properties.Add(new PSNoteProperty("Count", 42)); + + var columns = new List + { + new DataTableColumn("Count", "$_.\"Count\"") + }; + + var row = TypeGetter.CastObjectToDataTableRow(obj, columns, 0); + + var columnKey = columns[0].ToString(); + Assert.IsType(row.Values[columnKey]); + Assert.Equal(42m, ((DecimalValue)row.Values[columnKey]).SortValue); + } + + [Fact] + public void CastObjectToDataTableRow_StringProperty_CreatesStringValue() + { + var obj = new PSObject(); + obj.Properties.Add(new PSNoteProperty("Name", "hello")); + + var columns = new List + { + new DataTableColumn("Name", "$_.\"Name\"") + }; + + var row = TypeGetter.CastObjectToDataTableRow(obj, columns, 0); + + var columnKey = columns[0].ToString(); + Assert.IsType(row.Values[columnKey]); + } + + [Fact] + public void CastObjectToDataTableRow_NullProperty_ReturnsEmptyDisplayValue() + { + var obj = new PSObject(); + obj.Properties.Add(new PSNoteProperty("Name", null)); + + var columns = new List + { + new DataTableColumn("Name", "$_.\"Name\"") + }; + + var row = TypeGetter.CastObjectToDataTableRow(obj, columns, 0); + + var columnKey = columns[0].ToString(); + Assert.Equal(string.Empty, row.Values[columnKey].DisplayValue); + } + + [Fact] + public void CastObjectToDataTableRow_MissingProperty_ReturnsEmptyDisplayValue() + { + var obj = new PSObject(); + obj.Properties.Add(new PSNoteProperty("Other", "value")); + + var columns = new List + { + new DataTableColumn("Missing", "$_.\"Missing\"") + }; + + var row = TypeGetter.CastObjectToDataTableRow(obj, columns, 0); + + var columnKey = columns[0].ToString(); + Assert.Equal(string.Empty, row.Values[columnKey].DisplayValue); + } + + [Fact] + public void CastObjectToDataTableRow_PrimitiveAccessor_ExtractsWholeObject() + { + var obj = new PSObject("hello world"); + + var columns = new List + { + new DataTableColumn("String", "$_") + }; + + var row = TypeGetter.CastObjectToDataTableRow(obj, columns, 0); + + var columnKey = columns[0].ToString(); + Assert.Equal("hello world", row.Values[columnKey].DisplayValue); + } + + [Fact] + public void CastObjectToDataTableRow_AnsiCodes_AreStripped() + { + var obj = new PSObject(); + obj.Properties.Add(new PSNoteProperty("Name", "\x1b[31mRed\x1b[0m")); + + var columns = new List + { + new DataTableColumn("Name", "$_.\"Name\"") + }; + + var row = TypeGetter.CastObjectToDataTableRow(obj, columns, 0); + + var columnKey = columns[0].ToString(); + Assert.Equal("Red", row.Values[columnKey].DisplayValue); + } + + [Fact] + public void CastObjectsToTableView_SetsColumnTypes() + { + var obj1 = new PSObject(); + obj1.Properties.Add(new PSNoteProperty("Count", 10)); + obj1.Properties.Add(new PSNoteProperty("Name", "test")); + + var obj2 = new PSObject(); + obj2.Properties.Add(new PSNoteProperty("Count", 20)); + obj2.Properties.Add(new PSNoteProperty("Name", "test2")); + + var result = TypeGetter.CastObjectsToTableView(new List { obj1, obj2 }); + + var countColumn = result.DataColumns.Find(c => c.Label == "Count"); + var nameColumn = result.DataColumns.Find(c => c.Label == "Name"); + + Assert.NotNull(countColumn); + Assert.NotNull(nameColumn); + // Count column should be decimal type (all values are numeric) + Assert.Equal(typeof(decimal).FullName, countColumn.StringType); + // Name column should be string type + Assert.Equal(typeof(string).FullName, nameColumn.StringType); + } + + [Fact] + public void CastObjectsToTableView_MultipleObjects_PreservesOrder() + { + var objects = new List(); + for (int i = 0; i < 5; i++) + { + var obj = new PSObject(); + obj.Properties.Add(new PSNoteProperty("Index", i)); + objects.Add(obj); + } + + var result = TypeGetter.CastObjectsToTableView(objects); + + Assert.Equal(5, result.Data.Count); + for (int i = 0; i < 5; i++) + { + Assert.Equal(i, result.Data[i].OriginalObjectIndex); + } + } +} diff --git a/tools/ide/.vscode/settings.json b/tools/ide/.vscode/settings.json new file mode 100644 index 0000000..23830fb --- /dev/null +++ b/tools/ide/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "git.ignoreLimitWarning": true +} diff --git a/tools/ide/launchSettings.json b/tools/ide/launchSettings.json new file mode 100644 index 0000000..10a0e5b --- /dev/null +++ b/tools/ide/launchSettings.json @@ -0,0 +1,46 @@ +{ + "profiles": { + "OCGV": { + "commandName": "Executable", + "executablePath": "pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView -Debug }\"", + "workingDirectory": "$(TargetDir)" + }, + "OCGV -Filter": { + "commandName": "Executable", + "executablePath": "pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Select-Object ProcessName, Id, CPU | Out-ConsoleGridView -Debug -Filter com }\"", + "workingDirectory": "$(TargetDir)" + }, + "OCGV -MinUi": { + "commandName": "Executable", + "executablePath": "pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView -MinUi }\"", + "workingDirectory": "$(TargetDir)" + }, + "OCGV -AllProperties": { + "commandName": "Executable", + "executablePath": "pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Out-ConsoleGridView -AllProperties }\"", + "workingDirectory": "$(TargetDir)" + }, + "OCGV Select-Object": { + "commandName": "Executable", + "executablePath": "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": "pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; gci | Out-ConsoleGridView -OutputMode: Single }\"", + "workingDirectory": "$(TargetDir)" + }, + "SHOT": { + "commandName": "Executable", + "executablePath": "pwsh.exe", + "commandLineArgs": "-NoProfile -NoLogo -NoExit -Command \"& { Import-Module '$(TargetPath)'; Get-Process | Show-ObjectTree }\"", + "workingDirectory": "$(TargetDir)" + } + } +} diff --git a/tools/initDevEnvironment.ps1 b/tools/initDevEnvironment.ps1 new file mode 100644 index 0000000..488e054 --- /dev/null +++ b/tools/initDevEnvironment.ps1 @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + Initializes the development environment for GraphicalTools. +.DESCRIPTION + Creates IDE support files (.sln, launchSettings.json, .vscode/settings.json) + that are .gitignored per the PowerShell team convention. Run this after cloning + the repository to enable Visual Studio and VS Code development/debugging. + + The template files are stored in tools/ide/ and copied to their expected locations. +#> + +param( + [switch]$Force +) + +$ErrorActionPreference = 'Stop' +Push-Location $PSScriptRoot/.. + +try { + $templateDir = "tools/ide" + + # --- Solution file --- + $slnPath = "GraphicalTools.sln" + if ($Force -or -not (Test-Path $slnPath)) { + Write-Host "Creating $slnPath..." -ForegroundColor Cyan + Remove-Item -Force GraphicalTools.slnx -ErrorAction SilentlyContinue + & dotnet new sln --name GraphicalTools --force --format sln | Out-Null + & dotnet sln add src/Microsoft.PowerShell.ConsoleGuiTools/Microsoft.PowerShell.ConsoleGuiTools.csproj --solution-folder src + & dotnet sln add src/Microsoft.PowerShell.OutGridView.Models/Microsoft.PowerShell.OutGridView.Models.csproj --solution-folder src + Write-Host " Created $slnPath" -ForegroundColor Green + } else { + Write-Host "Skipping $slnPath (already exists, use -Force to overwrite)" -ForegroundColor Yellow + } + + # --- Visual Studio launch profiles --- + $launchDest = "src/Microsoft.PowerShell.ConsoleGuiTools/Properties/launchSettings.json" + if ($Force -or -not (Test-Path $launchDest)) { + Write-Host "Creating $launchDest..." -ForegroundColor Cyan + New-Item -ItemType Directory -Force (Split-Path $launchDest) | Out-Null + + # Read template and replace pwsh.exe placeholder with actual path + $pwshPreview = "C:\Program Files\PowerShell\7-preview\pwsh.exe" + $pwshStable = "C:\Program Files\PowerShell\7\pwsh.exe" + if (Test-Path $pwshPreview) { + $pwsh = $pwshPreview + } elseif (Test-Path $pwshStable) { + $pwsh = $pwshStable + } else { + $pwsh = (Get-Command pwsh -ErrorAction SilentlyContinue).Source + if (-not $pwsh) { + Write-Warning "Could not find pwsh.exe. Launch profiles will need manual path correction." + $pwsh = "pwsh.exe" + } + } + + $json = Get-Content "$templateDir/launchSettings.json" -Raw + $json = $json.Replace('"pwsh.exe"', "`"$($pwsh.Replace('\', '\\'))`"") + Set-Content -Path $launchDest -Value $json + + Write-Host " Created $launchDest (using $pwsh)" -ForegroundColor Green + } else { + Write-Host "Skipping $launchDest (already exists, use -Force to overwrite)" -ForegroundColor Yellow + } + + # --- VS Code settings --- + $vscodeDest = ".vscode/settings.json" + if ($Force -or -not (Test-Path $vscodeDest)) { + Write-Host "Creating $vscodeDest..." -ForegroundColor Cyan + New-Item -ItemType Directory -Force .vscode | Out-Null + Copy-Item "$templateDir/.vscode/settings.json" $vscodeDest + Write-Host " Created $vscodeDest" -ForegroundColor Green + } else { + Write-Host "Skipping $vscodeDest (already exists, use -Force to overwrite)" -ForegroundColor Yellow + } + + Write-Host "`nDev environment initialized! Open GraphicalTools.sln in Visual Studio or the root folder in VS Code." -ForegroundColor Green +} finally { + Pop-Location +}