From ffff884be09bf3505d7aaed3727f20ee15304bc2 Mon Sep 17 00:00:00 2001 From: DearingDev Date: Fri, 17 Apr 2026 01:28:28 -0700 Subject: [PATCH 1/6] Improvements to input handling --- .gitignore | 1 + CLAUDE.md | 146 +++++++++++++++ .../functions/Show-ModuleCommandViewer.ps1 | 176 +++++++++++++++++- 3 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 4a9c356..cdd92f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ publish TestResults +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c613ec7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,146 @@ +# ModuleExplorer — Claude Context + +## Project Overview + +**ModuleExplorer** is a PowerShell TUI module for browsing installed PowerShell modules and their commands interactively. + +- **Repo**: https://github.com/DearingDev/ModuleExplorer +- **Language**: PowerShell 100% +- **Entry point**: `Show-ModuleExplorer` → calls `Show-ModuleCommandViewer` +- **Key features**: module list with filtering, command viewer, detailed help display + +### Project Structure + +``` +ModuleExplorer/ # Module source (.psm1, .psd1, functions) +build/ # Build scripts +tests/ # Pester tests +readme.md +``` + +### Install / Run + +```powershell +Install-Module -Name ModuleExplorer +Show-ModuleExplorer +``` + +--- + +## Dependency: PwshSpectreConsole + +All TUI rendering is done via **PwshSpectreConsole**, a PowerShell wrapper around the Spectre.Console .NET library. + +- **Repo**: https://github.com/ShaunLawrie/PwshSpectreConsole +- **Docs**: https://pwshspectreconsole.com/ +- **Latest stable**: v2.3.0 +- **Install**: `Install-Module PwshSpectreConsole -Scope CurrentUser` +- **Local path** (typical): `$env:PSModulePath` — usually `~\Documents\PowerShell\Modules\PwshSpectreConsole` + +### Key Commands by Category + +#### Prompts (user input) +| Command | Purpose | +|---|---| +| `Read-SpectreSelection` | Single-item selection list | +| `Read-SpectreMultiSelection` | Multi-item selection list | +| `Read-SpectreSelectionGrouped` | Grouped single selection | +| `Read-SpectreMultiSelectionGrouped` | Grouped multi selection | +| `Read-SpectreText` | Free text input | +| `Read-SpectreConfirm` | Yes/No confirmation | +| `Read-SpectrePause` | Press any key to continue | + +#### Formatting / Layout +| Command | Purpose | +|---|---| +| `Format-SpectrePanel` | Bordered panel/box around content | +| `Format-SpectreTable` | Rich table rendering | +| `Format-SpectreColumns` | Multi-column layout | +| `Format-SpectreRows` | Stack renderables vertically | +| `Format-SpectreGrid` | Grid layout | +| `Format-SpectreAligned` | Alignment wrapper | +| `Format-SpectreTree` | Tree/hierarchy display | +| `Format-SpectreBarChart` | Horizontal bar chart | +| `Format-SpectreBreakdownChart` | Breakdown/pie-style chart | +| `Format-SpectreJson` | Syntax-highlighted JSON | +| `Format-SpectreException` | Pretty exception display | +| `Format-SpectreTextPath` | Styled file path display | +| `Format-SpectrePadded` | Padding wrapper | +| `New-SpectreLayout` | Named layout regions | +| `New-SpectreGridRow` | Row for grid layout | +| `New-SpectreChartItem` | Item for chart commands | +| `Add-SpectreTableRow` | Add row to a table object | + +#### Writing / Output +| Command | Purpose | +|---|---| +| `Write-SpectreHost` | Markup-aware output (like `Write-Host`) | +| `Out-SpectreHost` | Pipe objects to Spectre output | +| `Write-SpectreRule` | Horizontal divider rule with label | +| `Write-SpectreFigletText` | Large ASCII/Figlet text | +| `Write-SpectreCalendar` | Calendar widget | +| `Get-SpectreEscapedText` | Escape markup characters in strings | + +#### Live / Async +| Command | Purpose | +|---|---| +| `Invoke-SpectreCommandWithStatus` | Spinner/status while running a scriptblock | +| `Invoke-SpectreCommandWithProgress` | Progress bar while running tasks | +| `Invoke-SpectreLive` | Live-updating renderable (e.g. live table) | +| `Add-SpectreJob` | Add a job for progress tracking | +| `Wait-SpectreJobs` | Wait for all tracked jobs to complete | +| `Invoke-SpectreScriptBlockQuietly` | Run scriptblock suppressing output | + +#### Config +| Command | Purpose | +|---|---| +| `Set-SpectreColors` | Set default accent/highlight colors | +| `Get-SpectreColors` | Get current color config | +| `Start-SpectreRecording` / `Stop-SpectreRecording` | Record terminal output to file | + +#### Demo / Help +| Command | Purpose | +|---|---| +| `Start-SpectreDemo` | Full interactive feature demo | +| `Get-SpectreDemoFeatures` | Show Spectre.Console feature list | +| `Get-SpectreDemoColors` | Show available colors | +| `Get-SpectreDemoEmoji` | Show available emoji names | +| `Open-PwshSpectreConsoleHelp` | Open docs in browser | + +### Markup Syntax + +PwshSpectreConsole uses Spectre.Console markup in strings passed to `Write-SpectreHost` and similar commands: + +```powershell +Write-SpectreHost "[bold red]Error:[/] Something went wrong" +Write-SpectreHost "[green]Success![/]" +Write-SpectreHost "[:warning:] Watch out" # emoji by name +``` + +Colors can be named (`red`, `blue`, `green`) or hex (`#ff5733`). Use `Get-SpectreEscapedText` to safely escape user-supplied strings before embedding in markup. + +### Common Patterns in This Project + +```powershell +# Selection list +$selected = Read-SpectreSelection -Choices $moduleNames -Title "Select a module" + +# Display in a panel +$content | Format-SpectrePanel -Title "Help" -Expand + +# Status spinner during slow operations +Invoke-SpectreCommandWithStatus -Title "Loading..." -ScriptBlock { + # slow work here +} + +# Table display +$table = Format-SpectreTable -Data $commands -Property Name, CommandType, Synopsis +``` + +--- + +## Development Notes + +- Run tests with Pester: `Invoke-Pester ./tests/` +- The module manifest is in `ModuleExplorer/ModuleExplorer.psd1` +- PwshSpectreConsole full docs: https://pwshspectreconsole.com/reference/demo/get-spectredemofeatures/ diff --git a/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 b/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 index f106288..25420de 100644 --- a/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 +++ b/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 @@ -193,6 +193,180 @@ function Show-ModuleCommandViewer { $maxNameLength = 30 # Default/initial max name length + # --- Input Handling ScriptBlocks --- + $handleDescriptionInput = { + switch ($keyInfo.Key) { + ([System.ConsoleKey]::UpArrow) { + if ($currentCommandIndex -gt 0) { + $currentCommandIndex-- + if ($currentCommandIndex -lt $commandListScrollOffset) { + $commandListScrollOffset = $currentCommandIndex # Snap to top + } + } + } + ([System.ConsoleKey]::DownArrow) { + if ($commandListTotalItems -gt 0 -and $currentCommandIndex -lt ($commandListTotalItems - 1)) { + $currentCommandIndex++ + if ($currentCommandIndex -ge ($commandListScrollOffset + $commandListPageSize - 2)) { # -2 to offset to account for the scroll indicators + $commandListScrollOffset++ # Scroll down one line + } + } + } + ([System.ConsoleKey]::RightArrow) { + if ($currentCommandIndex -ge 0 -and $currentCommandIndex -lt $filteredCommandObjects.Count) { + $currentCommandObjectForHelp = $filteredCommandObjects[$currentCommandIndex] + $rightPaneView = 'HelpOptions' + $currentHelpOptionIndex = 0 + $searchString = "" # Clear search when moving to help + } + } + ([System.ConsoleKey]::Enter) { + if ($currentCommandIndex -ge 0 -and $currentCommandIndex -lt $filteredCommandObjects.Count) { + $currentCommandObjectForHelp = $filteredCommandObjects[$currentCommandIndex] + $rightPaneView = 'HelpOptions' + $currentHelpOptionIndex = 0 + $searchString = "" + } + } + ([System.ConsoleKey]::LeftArrow), ([System.ConsoleKey]::Backspace) { + if ($searchString.Length -gt 0) { + $searchString = $searchString.Substring(0, $searchString.Length - 1) + $currentCommandIndex = 0 + $commandListScrollOffset = 0 + } elseif ($keyInfo.Key -eq [System.ConsoleKey]::LeftArrow) { return $null } + } + } + } + + $handleHelpOptionsInput = { + switch ($keyInfo.Key) { + ([System.ConsoleKey]::UpArrow) { + if ($currentHelpOptionIndex -gt 0) { $currentHelpOptionIndex-- } + } + ([System.ConsoleKey]::DownArrow) { + if ($currentHelpOptionIndex -lt ($helpOptions.Count - 1)) { $currentHelpOptionIndex++ } + } + ([System.ConsoleKey]::RightArrow), ([System.ConsoleKey]::Enter) { + $selectedHelpType = $helpOptions[$currentHelpOptionIndex] + $currentHelpContentLines = @("[grey]Fetching help...[/]") + $helpContentScrollOffset = 0 + + if ($selectedHelpType -eq "Parameters") { + $allParamsCollection = $currentCommandObjectForHelp.CommandInfo.Parameters.Values + $tempParameterList = New-Object System.Collections.Generic.List[System.Management.Automation.ParameterMetadata] + + $nonCommonParamsOutput = [System.Management.Automation.ParameterMetadata[]]( + $allParamsCollection | Where-Object { $commonParameterNames -notcontains $_.Name } | Sort-Object Name + ) + if ($nonCommonParamsOutput) { $tempParameterList.AddRange($nonCommonParamsOutput) } + + $commonParamsFromCmdOutput = [System.Management.Automation.ParameterMetadata[]]( + $allParamsCollection | Where-Object { $commonParameterNames -contains $_.Name } | Sort-Object Name + ) + if ($commonParamsFromCmdOutput) { $tempParameterList.AddRange($commonParamsFromCmdOutput) } + + $commandParametersForHelp = $tempParameterList.ToArray() + $currentParameterIndex = 0 + $parameterListScrollOffset = 0 + $rightPaneView = 'ParameterList' + } elseif ($selectedHelpType -eq "Online") { + $currentHelpContentLines = @("[yellow]Press Right Arrow or Enter to open online help (if available), or Left Arrow to go back.[/]") + $rightPaneView = 'HelpContent' + } else { + $rightPaneView = 'HelpContent' + $LiveContext.Refresh() + try { + $helpText = "" + switch($selectedHelpType) { + "Examples" { $helpText = Get-Help $currentCommandObjectForHelp.CommandInfo -Examples | Out-String } + "Detailed" { $helpText = Get-Help $currentCommandObjectForHelp.CommandInfo -Detailed | Out-String } + "Full" { $helpText = Get-Help $currentCommandObjectForHelp.CommandInfo -Full | Out-String } + } + $currentHelpContentLines = ($helpText | Get-SpectreEscapedText) -split "`r?`n" + if ($currentHelpContentLines.Count -eq 0 -or ($currentHelpContentLines.Count -eq 1 -and [string]::IsNullOrWhiteSpace($currentHelpContentLines[0]))) { + $currentHelpContentLines = @("grey[/]") + } + } catch { + $currentHelpContentLines = @(("[red]Could not retrieve help: $($_.Exception.Message | Get-SpectreEscapedText)[/]" -split "`r?`n")) + } + } + } + ([System.ConsoleKey]::LeftArrow) { # Go back to Description view + $rightPaneView = 'Description' + $currentCommandObjectForHelp = $null + $currentHelpContentLines = @(); $helpContentScrollOffset = 0 + } + } + } + + $handleParameterListInput = { + switch ($keyInfo.Key) { + ([System.ConsoleKey]::UpArrow) { + if ($currentParameterIndex -gt 0) { + $currentParameterIndex-- + if ($currentParameterIndex -lt $parameterListScrollOffset) { $parameterListScrollOffset = $currentParameterIndex } + } + } + ([System.ConsoleKey]::DownArrow) { + if ($commandParametersForHelp.Count -gt 0 -and $currentParameterIndex -lt ($commandParametersForHelp.Count - 1)) { + $currentParameterIndex++ + if ($currentParameterIndex -ge ($parameterListScrollOffset + $parameterListPageSize)) { $parameterListScrollOffset++ } + } + } + ([System.ConsoleKey]::RightArrow), ([System.ConsoleKey]::Enter) { + if ($commandParametersForHelp.Count -gt 0 -and $currentParameterIndex -ge 0 -and $currentParameterIndex -lt $commandParametersForHelp.Count) { + $currentParameterObjectForHelp = $commandParametersForHelp[$currentParameterIndex] # This is ParameterMetadata + $currentHelpContentLines = @("[grey]Fetching parameter help...[/]") + $helpContentScrollOffset = 0 + $rightPaneView = 'ParameterHelpContent' + $LiveContext.Refresh() + try { + $paramHelpText = Get-Help $currentCommandObjectForHelp.Name -Parameter $currentParameterObjectForHelp.Name | Out-String + if ([string]::IsNullOrWhiteSpace($paramHelpText) -and $currentParameterObjectForHelp.HelpMessage) { + $paramHelpText = $currentParameterObjectForHelp.HelpMessage + } + + if (-not [string]::IsNullOrWhiteSpace($paramHelpText)) { + $currentHelpContentLines = ($paramHelpText | Get-SpectreEscapedText) -split "`r?`n" + } else { $currentHelpContentLines = @("grey[/]") } + } catch { + $currentHelpContentLines = @(("[red]Could not retrieve help for parameter '$($currentParameterObjectForHelp.Name)': $($_.Exception.Message | Get-SpectreEscapedText)[/]" -split "`r?`n")) + } + } + } + ([System.ConsoleKey]::LeftArrow) { # Go back to Help Options + $rightPaneView = 'HelpOptions' + $commandParametersForHelp = @() + $currentParameterObjectForHelp = $null + } + } + } + + $handleHelpContentInput = { + switch ($keyInfo.Key) { + ([System.ConsoleKey]::LeftArrow) { # Go back to previous view + if ($rightPaneView -eq 'ParameterHelpContent') { $rightPaneView = 'ParameterList' } + else { $rightPaneView = 'HelpOptions' } + $currentHelpContentLines = @(); $helpContentScrollOffset = 0 # Clear content + } + ([System.ConsoleKey]::RightArrow), ([System.ConsoleKey]::Enter) { # Only for Online help + if ($rightPaneView -eq 'HelpContent' -and $helpOptions[$currentHelpOptionIndex] -eq "Online") { + try { Get-Help $currentCommandObjectForHelp.CommandInfo -Online } + catch { $currentHelpContentLines = @("[red]Could not retrieve online help. Press Left to go back.[/]") } + } + } + ([System.ConsoleKey]::UpArrow) { # Scroll up + if ($helpContentScrollOffset -gt 0) { $helpContentScrollOffset-- } + } + ([System.ConsoleKey]::DownArrow) { # Scroll down + if (($helpContentScrollOffset + $helpContentPageSize) -lt $currentHelpContentLines.Count) { + $helpContentScrollOffset++ + } + } + } + } + # --- End Input Handling ScriptBlocks --- + try { while ($true) { # Recalculate dynamic page sizes if console was resized @@ -694,8 +868,6 @@ function Show-ModuleCommandViewer { Read-SpectrePause -Message "[grey]Press Enter to acknowledge error and return...[/]" -NoNewline return $null } - # This return should ideally not be reached if Escape is the main exit. - return $null } # End Invoke-SpectreLive ScriptBlock Clear-Host # Clean up the console after exiting the live display From 80a94fbd500fe4806359eb8613338a0358f65f8d Mon Sep 17 00:00:00 2001 From: DearingDev Date: Fri, 17 Apr 2026 03:33:20 -0700 Subject: [PATCH 2/6] Phase 1: Extract UI constants and internal helpers - Add internal/scripts/UIConfig.ps1: module-scoped $script:UIConfig hashtable centralizing all colors, layout constants, display strings, HelpOptions list, and CommonParameters list that were previously hardcoded across both public functions. - Add internal/functions/Get-ScrollableListView.ps1: single reusable function replacing three near-identical slice+indicator rendering blocks (command list, parameter list, help content) in Show-ModuleCommandViewer. - Add internal/functions/Get-CommandHelpContent.ps1: extracts the help-fetching logic (Examples/Detailed/Full/Online) that was copy-pasted into both the RightArrow and Enter handlers of the HelpOptions view (~50 lines x2). - Add internal/functions/Get-SortedParameterList.ps1: extracts the non-common-first parameter sort that was duplicated 3 times in the handler blocks and dead scriptblocks. - Add internal/functions/Get-ParameterHelpContent.ps1: extracts the Get-Help -Parameter fetch+escape block duplicated in both RightArrow and Enter handlers of the ParameterList view. No public function behavior is changed in this phase. The public functions continue to work identically; the helpers are wired in during Phase 2. --- .../functions/Get-CommandHelpContent.ps1 | 71 ++++++++++++ .../functions/Get-ParameterHelpContent.ps1 | 68 +++++++++++ .../functions/Get-ScrollableListView.ps1 | 108 ++++++++++++++++++ .../functions/Get-SortedParameterList.ps1 | 61 ++++++++++ ModuleExplorer/internal/scripts/UIConfig.ps1 | 66 +++++++++++ 5 files changed, 374 insertions(+) create mode 100644 ModuleExplorer/internal/functions/Get-CommandHelpContent.ps1 create mode 100644 ModuleExplorer/internal/functions/Get-ParameterHelpContent.ps1 create mode 100644 ModuleExplorer/internal/functions/Get-ScrollableListView.ps1 create mode 100644 ModuleExplorer/internal/functions/Get-SortedParameterList.ps1 create mode 100644 ModuleExplorer/internal/scripts/UIConfig.ps1 diff --git a/ModuleExplorer/internal/functions/Get-CommandHelpContent.ps1 b/ModuleExplorer/internal/functions/Get-CommandHelpContent.ps1 new file mode 100644 index 0000000..c20ed25 --- /dev/null +++ b/ModuleExplorer/internal/functions/Get-CommandHelpContent.ps1 @@ -0,0 +1,71 @@ +<# +.SYNOPSIS + Fetches and formats help content for a PowerShell command into Spectre-safe string lines. + +.DESCRIPTION + Get-CommandHelpContent centralizes the help-fetching logic that was previously copy-pasted + into both the RightArrow and Enter key handlers for the HelpOptions view inside + Show-ModuleCommandViewer (~50 lines duplicated twice). + + The function retrieves the appropriate help text via Get-Help, escapes it for Spectre + Console markup using Get-SpectreEscapedText, and splits it into individual lines ready + to be stored in the $currentHelpContentLines variable. + + For the "Online" help type a special single-item array is returned with an instructional + prompt rather than fetched content (the actual browser launch is handled separately by + the input handler). + +.PARAMETER HelpType + The category of help to retrieve. Must be one of: Examples, Detailed, Full, Online. + (The "Parameters" option is handled separately by Get-SortedParameterList / + Get-ParameterHelpContent and should not be passed here.) + +.PARAMETER CommandInfo + The original CommandInfo object (e.g. from Get-Command). Passed directly to Get-Help. + +.OUTPUTS + System.String[] + An array of Spectre-escaped strings, one element per line of formatted help output. + Never returns $null — returns a one-element grey placeholder on empty/error content. + +.EXAMPLE + $lines = Get-CommandHelpContent -HelpType 'Examples' -CommandInfo $cmd.CommandInfo + # Store in $currentHelpContentLines for the viewer to render +#> +function Get-CommandHelpContent { + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] + [ValidateSet('Examples', 'Detailed', 'Full', 'Online')] + [string]$HelpType, + + [Parameter(Mandatory)] + $CommandInfo + ) + + # Online help is launched interactively by the input handler; return a prompt string. + if ($HelpType -eq 'Online') { + return @('[yellow]Press Right Arrow or Enter to open online help (if available), or Left Arrow to go back.[/]') + } + + try { + $helpText = switch ($HelpType) { + 'Examples' { Get-Help $CommandInfo -Examples | Out-String } + 'Detailed' { Get-Help $CommandInfo -Detailed | Out-String } + 'Full' { Get-Help $CommandInfo -Full | Out-String } + } + + $lines = ($helpText | Get-SpectreEscapedText) -split "`r?`n" + + if ($lines.Count -eq 0 -or ($lines.Count -eq 1 -and [string]::IsNullOrWhiteSpace($lines[0]))) { + return @('[grey](No content for this help type)[/]') + } + + return $lines + } + catch { + $escapedMsg = $_.Exception.Message | Get-SpectreEscapedText + return @("[red]Could not retrieve help: $escapedMsg[/]") + } +} diff --git a/ModuleExplorer/internal/functions/Get-ParameterHelpContent.ps1 b/ModuleExplorer/internal/functions/Get-ParameterHelpContent.ps1 new file mode 100644 index 0000000..311186b --- /dev/null +++ b/ModuleExplorer/internal/functions/Get-ParameterHelpContent.ps1 @@ -0,0 +1,68 @@ +<# +.SYNOPSIS + Fetches and formats parameter-specific help content into Spectre-safe string lines. + +.DESCRIPTION + Get-ParameterHelpContent extracts the parameter help-fetching block that was + duplicated inside both the RightArrow and Enter key handlers for the ParameterList + view in Show-ModuleCommandViewer. + + It calls Get-Help -Parameter to retrieve the help for a specific parameter, falls back + to the ParameterMetadata.HelpMessage property if Get-Help returns nothing, and then + escapes the result with Get-SpectreEscapedText before splitting into lines. + +.PARAMETER CommandName + The name of the command whose parameter help is being retrieved (string, not + CommandInfo — Get-Help -Parameter requires the command name). + +.PARAMETER ParameterName + The name of the parameter to look up. + +.PARAMETER FallbackHelpMessage + Optional. A pre-fetched HelpMessage string from ParameterMetadata.HelpMessage. + Used when Get-Help -Parameter returns empty content. + +.OUTPUTS + System.String[] + An array of Spectre-escaped strings, one element per line. + Never returns $null — returns a one-element grey placeholder when no content is found. + +.EXAMPLE + $lines = Get-ParameterHelpContent ` + -CommandName $state.CurrentCommand.Name ` + -ParameterName $selectedParam.Name ` + -FallbackHelpMessage $selectedParam.HelpMessage +#> +function Get-ParameterHelpContent { + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] + [string]$CommandName, + + [Parameter(Mandatory)] + [string]$ParameterName, + + [string]$FallbackHelpMessage + ) + + try { + $rawText = Get-Help $CommandName -Parameter $ParameterName | Out-String + + # Fallback to ParameterMetadata.HelpMessage when Get-Help returns nothing + if ([string]::IsNullOrWhiteSpace($rawText) -and -not [string]::IsNullOrWhiteSpace($FallbackHelpMessage)) { + $rawText = $FallbackHelpMessage + } + + if (-not [string]::IsNullOrWhiteSpace($rawText)) { + return ($rawText | Get-SpectreEscapedText) -split "`r?`n" + } + + return @('[grey](No specific help message found for this parameter.)[/]') + } + catch { + $escapedName = $ParameterName | Get-SpectreEscapedText + $escapedMsg = $_.Exception.Message | Get-SpectreEscapedText + return @("[red]Could not retrieve help for parameter '$escapedName': $escapedMsg[/]") + } +} diff --git a/ModuleExplorer/internal/functions/Get-ScrollableListView.ps1 b/ModuleExplorer/internal/functions/Get-ScrollableListView.ps1 new file mode 100644 index 0000000..0f65d4b --- /dev/null +++ b/ModuleExplorer/internal/functions/Get-ScrollableListView.ps1 @@ -0,0 +1,108 @@ +<# +.SYNOPSIS + Returns a window into a list of items suitable for rendering in a scrollable TUI pane. + +.DESCRIPTION + Get-ScrollableListView encapsulates the repeated "slice + decorate with scroll indicators" + pattern that previously existed in three separate locations inside Show-ModuleCommandViewer + (command list, parameter list, and help-content scrolling). + + The function takes the full item list, the current selection index, the scroll offset, and + the page size, then returns the slice of lines that should be visible, prepended/appended + with Spectre Console grey scroll indicators ([grey] ↑ ...[/] / [grey] ↓ ...[/]) when + content extends beyond the visible window. + + An optional -ItemFormatter scriptblock lets callers style each item. The scriptblock + receives: + param($Item, [int]$AbsoluteIndex, [bool]$IsSelected) + and must return a single styled string. If omitted, items are rendered as plain strings + with a yellow-bold ">" prefix on the selected item. + +.PARAMETER Items + The full, unsliced array of items to display. + +.PARAMETER SelectedIndex + Zero-based index of the currently selected item within the Items array. + +.PARAMETER ScrollOffset + Zero-based index of the first visible item (the current scroll position). + +.PARAMETER PageSize + How many content rows are available for display (excluding scroll-indicator rows). + +.PARAMETER ItemFormatter + Optional scriptblock to style each row. Receives ($Item, $AbsoluteIndex, $IsSelected). + Must return a single string with Spectre Console markup. + +.OUTPUTS + System.String[] + An array of Spectre Console markup strings ready to be piped into Format-SpectreRows. + +.EXAMPLE + $lines = Get-ScrollableListView -Items $myItems -SelectedIndex 3 -ScrollOffset 1 -PageSize 10 + $lines | Format-SpectreRows | Format-SpectrePanel -Expand -Border Rounded +#> +function Get-ScrollableListView { + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [array]$Items, + + [Parameter(Mandatory)] + [int]$SelectedIndex, + + [Parameter(Mandatory)] + [int]$ScrollOffset, + + [Parameter(Mandatory)] + [int]$PageSize, + + # Optional custom row formatter: param($Item, [int]$Index, [bool]$IsSelected) → string + [scriptblock]$ItemFormatter + ) + + $result = [System.Collections.Generic.List[string]]::new() + + if ($Items.Count -eq 0 -or $SelectedIndex -lt 0) { + $result.Add('[grey](No items to display)[/]') + return $result.ToArray() + } + + # Scroll-indicator rows consume 1 row each; reserve slots for them so that + # the visible content area never exceeds PageSize total rows. + $hasUp = $ScrollOffset -gt 0 + $contentStartIndex = $ScrollOffset + # -2 leaves room for both a top and bottom indicator; -1 if only one direction is needed. + # We pessimistically reserve 2 rows so the slice is stable regardless of scroll position. + $contentSlotCount = [System.Math]::Max(1, $PageSize - 2) + $contentEndIndex = [System.Math]::Min( + ($contentStartIndex + $contentSlotCount - 1), + ($Items.Count - 1) + ) + $hasDown = $contentEndIndex -lt ($Items.Count - 1) + + if ($hasUp) { $result.Add('[grey] ↑ ...[/]') } + + for ($i = $contentStartIndex; $i -le $contentEndIndex; $i++) { + if ($i -lt 0 -or $i -ge $Items.Count) { continue } + + $isSelected = ($i -eq $SelectedIndex) + $item = $Items[$i] + + if ($null -ne $ItemFormatter) { + $line = & $ItemFormatter $item $i $isSelected + } else { + # Default: plain text with selection cursor + $text = if ($item -is [string]) { $item } else { $item.ToString() } + $line = if ($isSelected) { "[yellow bold]>[/] $text" } else { " $text" } + } + + $result.Add($line) + } + + if ($hasDown) { $result.Add('[grey] ↓ ...[/]') } + + return $result.ToArray() +} diff --git a/ModuleExplorer/internal/functions/Get-SortedParameterList.ps1 b/ModuleExplorer/internal/functions/Get-SortedParameterList.ps1 new file mode 100644 index 0000000..e36b8ee --- /dev/null +++ b/ModuleExplorer/internal/functions/Get-SortedParameterList.ps1 @@ -0,0 +1,61 @@ +<# +.SYNOPSIS + Returns a command's parameters sorted with non-common parameters first. + +.DESCRIPTION + Get-SortedParameterList extracts the parameter-sorting logic that was duplicated + three times inside Show-ModuleCommandViewer (once in $handleParameterListInput, + once in the RightArrow handler for HelpOptions, and once in the Enter handler for + HelpOptions). + + The sort order is: + 1. Non-common parameters, alphabetically by name. + 2. Common parameters (Verbose, Debug, ErrorAction, etc.), alphabetically by name. + + This places the most relevant parameters at the top of the list while keeping the + common PowerShell parameters accessible but visually separated (callers typically + render them in grey). + +.PARAMETER Parameters + The .Values collection from a CommandInfo.Parameters dictionary + (System.Management.Automation.ParameterMetadata objects). + +.OUTPUTS + System.Management.Automation.ParameterMetadata[] + Sorted array of ParameterMetadata objects. Returns an empty array if the command + has no parameters. + +.EXAMPLE + $sorted = Get-SortedParameterList -Parameters $commandInfo.Parameters.Values + # Use $sorted as the $commandParametersForHelp list in the viewer +#> +function Get-SortedParameterList { + [CmdletBinding()] + [OutputType([System.Management.Automation.ParameterMetadata[]])] + param( + [Parameter(Mandatory)] + [AllowNull()] + [AllowEmptyCollection()] + $Parameters # Accepts .Values from CommandInfo.Parameters (ICollection) + ) + + $commonNames = $script:UIConfig.CommonParameters + + $ordered = [System.Collections.Generic.List[System.Management.Automation.ParameterMetadata]]::new() + + $nonCommon = @( + $Parameters | + Where-Object { $commonNames -notcontains $_.Name } | + Sort-Object Name + ) + if ($nonCommon) { $ordered.AddRange([System.Management.Automation.ParameterMetadata[]]$nonCommon) } + + $common = @( + $Parameters | + Where-Object { $commonNames -contains $_.Name } | + Sort-Object Name + ) + if ($common) { $ordered.AddRange([System.Management.Automation.ParameterMetadata[]]$common) } + + return $ordered.ToArray() +} diff --git a/ModuleExplorer/internal/scripts/UIConfig.ps1 b/ModuleExplorer/internal/scripts/UIConfig.ps1 new file mode 100644 index 0000000..672bf7e --- /dev/null +++ b/ModuleExplorer/internal/scripts/UIConfig.ps1 @@ -0,0 +1,66 @@ +<# +.SYNOPSIS + Centralizes all hardcoded UI configuration values for the ModuleExplorer module. + +.DESCRIPTION + This script is dot-sourced at module import time and populates the module-scoped + $script:UIConfig hashtable. All colors, layout constants, display strings, and + static lists that were previously scattered throughout Show-ModuleCommandViewer + and Show-ModuleExplorer are consolidated here. + + Modify this file to retheme or reconfigure the module's TUI appearance without + touching any function logic. +#> + +$script:UIConfig = @{ + + # -- Color / theme tokens -------------------------------------------------- + # Values are Spectre Console markup color names (or "bold"/"italic" modifiers). + Colors = @{ + Cmdlet = 'green' + Function = 'blue' + Alias = 'magenta' + Highlight = 'yellow bold' # Selected item cursor + Muted = 'grey' # Scroll indicators, empty-state text + Error = 'bold red' + Success = 'italic green' + Title = 'green bold' # Module/command viewer title + Exit = 'mediumpurple3_1' + Refresh = 'steelblue1_1' + } + + # -- Layout constants ------------------------------------------------------ + Layout = @{ + CommandPaneRatio = 1 # Left pane relative width (vs. RightPaneRatio) + RightPaneRatio = 3 # Right pane relative width + FixedRowsOverhead = 4 # Rows consumed by title, instructions, borders + MinPageSize = 1 # Minimum scrollable page height (rows) + MinNameLength = 10 # Minimum truncated command name width (chars) + PanelBorder = 'Rounded' + DefaultPageSize = 15 + } + + # -- Display strings ------------------------------------------------------- + # Centralizes all user-visible text so they can be changed in one place. + Strings = @{ + ExitChoice = '<-- Exit' + RefreshChoice = 'Refresh List' + ShowGrouped = 'Show Grouped Modules' + HideGrouped = 'Hide Grouped Modules' + NoModulesFound = 'No PowerShell modules found.' + SelectPrompt = 'Select a PowerShell Module to Explore (or Exit):' + LoadingModules = 'Loading PowerShell Modules...' + LoadingCommands = 'Loading command list...' + SelectCommand = 'Select a command to see description.' + } + + # -- Static option lists --------------------------------------------------- + HelpOptions = @('Examples', 'Detailed', 'Full', 'Online', 'Parameters') + + # Full list of PowerShell common parameters (used to sort/style param lists) + CommonParameters = @( + 'Verbose', 'Debug', 'ErrorAction', 'ErrorVariable', 'WarningAction', + 'WarningVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', + 'InformationAction', 'InformationVariable', 'ProgressAction' + ) +} From e54e6ab6e8b39965755da7744af4f512c39a31d0 Mon Sep 17 00:00:00 2001 From: DearingDev Date: Fri, 17 Apr 2026 13:02:33 -0700 Subject: [PATCH 3/6] Refactor of Show-ModuleCommandView to use internal helpers. --- .../functions/Show-ModuleCommandViewer.ps1 | 787 ++---------------- .../functions/Get-ScrollableListView.ps1 | 5 +- .../functions/Invoke-ViewerInputHandler.ps1 | 221 +++++ .../functions/Update-ViewerLayout.ps1 | 173 ++++ 4 files changed, 464 insertions(+), 722 deletions(-) create mode 100644 ModuleExplorer/internal/functions/Invoke-ViewerInputHandler.ps1 create mode 100644 ModuleExplorer/internal/functions/Update-ViewerLayout.ps1 diff --git a/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 b/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 index 25420de..6862e01 100644 --- a/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 +++ b/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Displays an interactive viewer for commands (cmdlets, functions, aliases) within a selected PowerShell module. @@ -96,13 +96,6 @@ function Show-ModuleCommandViewer { [PSObject]$SelectedModule ) - # Define common PowerShell parameters - $commonParameterNames = @( - 'Verbose', 'Debug', 'ErrorAction', 'ErrorVariable', 'WarningAction', - 'WarningVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', - 'InformationAction', 'InformationVariable', 'ProgressAction' - ) - $commands = Get-Command -Module $SelectedModule.Name | Sort-Object CommandType, Name if (-not $commands) { @@ -111,7 +104,7 @@ function Show-ModuleCommandViewer { return } - # Pre-fetch command details, including synopsis + # Pre-fetch command details including synopsis $allCommandObjects = @($commands | ForEach-Object { $synopsis = "" try { @@ -125,7 +118,6 @@ function Show-ModuleCommandViewer { } } catch { Write-Verbose "Failed to get help for command '$($_.Name)': $($_.Exception.Message)" - # Falls back to empty synopsis string } [PSCustomObject]@{ @@ -134,7 +126,7 @@ function Show-ModuleCommandViewer { Source = $_.Source Definition = $_.Definition Synopsis = $synopsis - CommandInfo = $_ # Store the OG object + CommandInfo = $_ } }) @@ -144,731 +136,84 @@ function Show-ModuleCommandViewer { return } - # Interactive Help Viewer with Invoke-SpectreLive - # Ratios are king, not sure I can resize without them - $initialCommandListContent = Write-SpectreHost "[grey]Loading command list...[/]" -PassThru | Format-SpectrePanel -Header "[bold]Commands[/]" -Expand -Border Rounded - $initialRightPanelContent = Write-SpectreHost "[grey]Select a command to see description.[/]" -PassThru | Format-SpectrePanel -Header "[bold]Description[/]" -Expand -Border Rounded - - $commandListPaneLayout = New-SpectreLayout -Name "commandListPane" -Data $initialCommandListContent -Ratio 1 - $rightPaneLayout = New-SpectreLayout -Name "rightPane" -Data $initialRightPanelContent -Ratio 3 - $combinedPanel = New-SpectreLayout -Name "combinedPanel" -Columns @($commandListPaneLayout , $rightPaneLayout) -Ratio 20 - - $titleRenderable = Write-SpectreHost "[green bold]Cmdlets[/], [blue bold]Functions[/], and [magenta bold]Aliases[/] in $($SelectedModule.Name)" -PassThru | Format-SpectreAligned -HorizontalAlignment Center - $instructionsText = "[grey](↑/↓ Navigate | → Select | ← Back | Type to Search | Esc Exit)[/]" - $instructionsRenderable = Write-SpectreHost $instructionsText -PassThru | Format-SpectreAligned -HorizontalAlignment Center - $layout = New-SpectreLayout -Name "root" -Rows @($titleRenderable, $combinedPanel, $instructionsRenderable) + $cfg = $script:UIConfig + + # Build layout + $initialCmdContent = Write-SpectreHost "[$($cfg.Colors.Muted)]$($cfg.Strings.LoadingCommands)[/]" -PassThru | + Format-SpectrePanel -Header '[bold]Commands[/]' -Expand -Border Rounded + $initialRightContent = Write-SpectreHost "[$($cfg.Colors.Muted)]$($cfg.Strings.SelectCommand)[/]" -PassThru | + Format-SpectrePanel -Header '[bold]Description[/]' -Expand -Border Rounded + + $commandListPaneLayout = New-SpectreLayout -Name 'commandListPane' -Data $initialCmdContent -Ratio $cfg.Layout.CommandPaneRatio + $rightPaneLayout = New-SpectreLayout -Name 'rightPane' -Data $initialRightContent -Ratio $cfg.Layout.RightPaneRatio + $combinedPanel = New-SpectreLayout -Name 'combinedPanel' -Columns @($commandListPaneLayout, $rightPaneLayout) -Ratio 20 + + $titleText = "[$($cfg.Colors.Cmdlet) bold]Cmdlets[/], [$($cfg.Colors.Function) bold]Functions[/], and [$($cfg.Colors.Alias) bold]Aliases[/] in $($SelectedModule.Name)" + $titleRow = Write-SpectreHost $titleText -PassThru | Format-SpectreAligned -HorizontalAlignment Center + $instrRow = Write-SpectreHost '[grey](↑/↓ Navigate | → Select | ← Back | Type to Search | Esc Exit)[/]' -PassThru | + Format-SpectreAligned -HorizontalAlignment Center + $layout = New-SpectreLayout -Name 'root' -Rows @($titleRow, $combinedPanel, $instrRow) + + # Initialize state + $dynamicPageSize = [Math]::Max($cfg.Layout.MinPageSize, $Host.UI.RawUI.WindowSize.Height - $cfg.Layout.FixedRowsOverhead) + $state = @{ + AllCommands = $allCommandObjects + FilteredCommands = $allCommandObjects + View = 'Description' + CommandIndex = 0 + CommandListScrollOffset = 0 + CommandListPageSize = $dynamicPageSize + SearchString = '' + MaxNameLength = $cfg.Layout.MinNameLength + HelpOptionIndex = 0 + HelpOptions = $cfg.HelpOptions + Parameters = @() + ParameterIndex = 0 + ParameterListScrollOffset = 0 + ParameterListPageSize = $dynamicPageSize + CurrentCommand = $null + CurrentParameter = $null + HelpContentLines = @() + HelpContentScrollOffset = 0 + HelpContentPageSize = $dynamicPageSize + LiveContext = $null + } Invoke-SpectreLive -Data $layout -ScriptBlock { - param ( - [Spectre.Console.LiveDisplayContext] $LiveContext - ) - - # Set default variables for the live UI - $currentCommandIndex = 0 - $currentHelpOptionIndex = 0 - $currentParameterIndex = 0 - $rightPaneView = 'Description' - - $searchString = "" - $filteredCommandObjects = $allCommandObjects - $currentCommandObjectForHelp = $null - $currentParameterObjectForHelp = $null - $commandParametersForHelp = @() # Holds Parameter objects, sorted with common params last - - # Dynamic sizing based on console height - $fixedRowsOverhead = 4 # Approximate rows for title, instructions, borders - $dynamicPageSize = ($Host.UI.RawUI.WindowSize.Height - $fixedRowsOverhead) - if ($dynamicPageSize -lt 1) {$dynamicPageSize = 1} # Ensure at least 1 - - $commandListPageSize = $dynamicPageSize - $commandListScrollOffset = 0 - - $helpOptions = @("Examples", "Detailed", "Full", "Online", "Parameters") - $currentHelpContentLines = @() # Stores text current help view - $helpContentScrollOffset = 0 - $helpContentPageSize = $dynamicPageSize - - $parameterListPageSize = $dynamicPageSize - $parameterListScrollOffset = 0 - - $maxNameLength = 30 # Default/initial max name length - - # --- Input Handling ScriptBlocks --- - $handleDescriptionInput = { - switch ($keyInfo.Key) { - ([System.ConsoleKey]::UpArrow) { - if ($currentCommandIndex -gt 0) { - $currentCommandIndex-- - if ($currentCommandIndex -lt $commandListScrollOffset) { - $commandListScrollOffset = $currentCommandIndex # Snap to top - } - } - } - ([System.ConsoleKey]::DownArrow) { - if ($commandListTotalItems -gt 0 -and $currentCommandIndex -lt ($commandListTotalItems - 1)) { - $currentCommandIndex++ - if ($currentCommandIndex -ge ($commandListScrollOffset + $commandListPageSize - 2)) { # -2 to offset to account for the scroll indicators - $commandListScrollOffset++ # Scroll down one line - } - } - } - ([System.ConsoleKey]::RightArrow) { - if ($currentCommandIndex -ge 0 -and $currentCommandIndex -lt $filteredCommandObjects.Count) { - $currentCommandObjectForHelp = $filteredCommandObjects[$currentCommandIndex] - $rightPaneView = 'HelpOptions' - $currentHelpOptionIndex = 0 - $searchString = "" # Clear search when moving to help - } - } - ([System.ConsoleKey]::Enter) { - if ($currentCommandIndex -ge 0 -and $currentCommandIndex -lt $filteredCommandObjects.Count) { - $currentCommandObjectForHelp = $filteredCommandObjects[$currentCommandIndex] - $rightPaneView = 'HelpOptions' - $currentHelpOptionIndex = 0 - $searchString = "" - } - } - ([System.ConsoleKey]::LeftArrow), ([System.ConsoleKey]::Backspace) { - if ($searchString.Length -gt 0) { - $searchString = $searchString.Substring(0, $searchString.Length - 1) - $currentCommandIndex = 0 - $commandListScrollOffset = 0 - } elseif ($keyInfo.Key -eq [System.ConsoleKey]::LeftArrow) { return $null } - } - } - } - - $handleHelpOptionsInput = { - switch ($keyInfo.Key) { - ([System.ConsoleKey]::UpArrow) { - if ($currentHelpOptionIndex -gt 0) { $currentHelpOptionIndex-- } - } - ([System.ConsoleKey]::DownArrow) { - if ($currentHelpOptionIndex -lt ($helpOptions.Count - 1)) { $currentHelpOptionIndex++ } - } - ([System.ConsoleKey]::RightArrow), ([System.ConsoleKey]::Enter) { - $selectedHelpType = $helpOptions[$currentHelpOptionIndex] - $currentHelpContentLines = @("[grey]Fetching help...[/]") - $helpContentScrollOffset = 0 - - if ($selectedHelpType -eq "Parameters") { - $allParamsCollection = $currentCommandObjectForHelp.CommandInfo.Parameters.Values - $tempParameterList = New-Object System.Collections.Generic.List[System.Management.Automation.ParameterMetadata] - - $nonCommonParamsOutput = [System.Management.Automation.ParameterMetadata[]]( - $allParamsCollection | Where-Object { $commonParameterNames -notcontains $_.Name } | Sort-Object Name - ) - if ($nonCommonParamsOutput) { $tempParameterList.AddRange($nonCommonParamsOutput) } - - $commonParamsFromCmdOutput = [System.Management.Automation.ParameterMetadata[]]( - $allParamsCollection | Where-Object { $commonParameterNames -contains $_.Name } | Sort-Object Name - ) - if ($commonParamsFromCmdOutput) { $tempParameterList.AddRange($commonParamsFromCmdOutput) } - - $commandParametersForHelp = $tempParameterList.ToArray() - $currentParameterIndex = 0 - $parameterListScrollOffset = 0 - $rightPaneView = 'ParameterList' - } elseif ($selectedHelpType -eq "Online") { - $currentHelpContentLines = @("[yellow]Press Right Arrow or Enter to open online help (if available), or Left Arrow to go back.[/]") - $rightPaneView = 'HelpContent' - } else { - $rightPaneView = 'HelpContent' - $LiveContext.Refresh() - try { - $helpText = "" - switch($selectedHelpType) { - "Examples" { $helpText = Get-Help $currentCommandObjectForHelp.CommandInfo -Examples | Out-String } - "Detailed" { $helpText = Get-Help $currentCommandObjectForHelp.CommandInfo -Detailed | Out-String } - "Full" { $helpText = Get-Help $currentCommandObjectForHelp.CommandInfo -Full | Out-String } - } - $currentHelpContentLines = ($helpText | Get-SpectreEscapedText) -split "`r?`n" - if ($currentHelpContentLines.Count -eq 0 -or ($currentHelpContentLines.Count -eq 1 -and [string]::IsNullOrWhiteSpace($currentHelpContentLines[0]))) { - $currentHelpContentLines = @("grey[/]") - } - } catch { - $currentHelpContentLines = @(("[red]Could not retrieve help: $($_.Exception.Message | Get-SpectreEscapedText)[/]" -split "`r?`n")) - } - } - } - ([System.ConsoleKey]::LeftArrow) { # Go back to Description view - $rightPaneView = 'Description' - $currentCommandObjectForHelp = $null - $currentHelpContentLines = @(); $helpContentScrollOffset = 0 - } - } - } - - $handleParameterListInput = { - switch ($keyInfo.Key) { - ([System.ConsoleKey]::UpArrow) { - if ($currentParameterIndex -gt 0) { - $currentParameterIndex-- - if ($currentParameterIndex -lt $parameterListScrollOffset) { $parameterListScrollOffset = $currentParameterIndex } - } - } - ([System.ConsoleKey]::DownArrow) { - if ($commandParametersForHelp.Count -gt 0 -and $currentParameterIndex -lt ($commandParametersForHelp.Count - 1)) { - $currentParameterIndex++ - if ($currentParameterIndex -ge ($parameterListScrollOffset + $parameterListPageSize)) { $parameterListScrollOffset++ } - } - } - ([System.ConsoleKey]::RightArrow), ([System.ConsoleKey]::Enter) { - if ($commandParametersForHelp.Count -gt 0 -and $currentParameterIndex -ge 0 -and $currentParameterIndex -lt $commandParametersForHelp.Count) { - $currentParameterObjectForHelp = $commandParametersForHelp[$currentParameterIndex] # This is ParameterMetadata - $currentHelpContentLines = @("[grey]Fetching parameter help...[/]") - $helpContentScrollOffset = 0 - $rightPaneView = 'ParameterHelpContent' - $LiveContext.Refresh() - try { - $paramHelpText = Get-Help $currentCommandObjectForHelp.Name -Parameter $currentParameterObjectForHelp.Name | Out-String - if ([string]::IsNullOrWhiteSpace($paramHelpText) -and $currentParameterObjectForHelp.HelpMessage) { - $paramHelpText = $currentParameterObjectForHelp.HelpMessage - } - - if (-not [string]::IsNullOrWhiteSpace($paramHelpText)) { - $currentHelpContentLines = ($paramHelpText | Get-SpectreEscapedText) -split "`r?`n" - } else { $currentHelpContentLines = @("grey[/]") } - } catch { - $currentHelpContentLines = @(("[red]Could not retrieve help for parameter '$($currentParameterObjectForHelp.Name)': $($_.Exception.Message | Get-SpectreEscapedText)[/]" -split "`r?`n")) - } - } - } - ([System.ConsoleKey]::LeftArrow) { # Go back to Help Options - $rightPaneView = 'HelpOptions' - $commandParametersForHelp = @() - $currentParameterObjectForHelp = $null - } - } - } - - $handleHelpContentInput = { - switch ($keyInfo.Key) { - ([System.ConsoleKey]::LeftArrow) { # Go back to previous view - if ($rightPaneView -eq 'ParameterHelpContent') { $rightPaneView = 'ParameterList' } - else { $rightPaneView = 'HelpOptions' } - $currentHelpContentLines = @(); $helpContentScrollOffset = 0 # Clear content - } - ([System.ConsoleKey]::RightArrow), ([System.ConsoleKey]::Enter) { # Only for Online help - if ($rightPaneView -eq 'HelpContent' -and $helpOptions[$currentHelpOptionIndex] -eq "Online") { - try { Get-Help $currentCommandObjectForHelp.CommandInfo -Online } - catch { $currentHelpContentLines = @("[red]Could not retrieve online help. Press Left to go back.[/]") } - } - } - ([System.ConsoleKey]::UpArrow) { # Scroll up - if ($helpContentScrollOffset -gt 0) { $helpContentScrollOffset-- } - } - ([System.ConsoleKey]::DownArrow) { # Scroll down - if (($helpContentScrollOffset + $helpContentPageSize) -lt $currentHelpContentLines.Count) { - $helpContentScrollOffset++ - } - } - } - } - # --- End Input Handling ScriptBlocks --- + param([Spectre.Console.LiveDisplayContext]$LiveContext) + $state.LiveContext = $LiveContext try { while ($true) { - # Recalculate dynamic page sizes if console was resized - # This doesn't work when shrinking the console, but it does when expanding - # Will need to revisit to see if I can resolve that issue. - $consoleHeight = $Host.UI.RawUI.WindowSize.Height - $consoleWidth = $Host.UI.RawUI.WindowSize.Width - - $newDynamicPageSize = ($consoleHeight - $fixedRowsOverhead) - if ($newDynamicPageSize -lt 1) {$newDynamicPageSize = 1} - - if ($newDynamicPageSize -ne $dynamicPageSize) { - $dynamicPageSize = $newDynamicPageSize - $commandListPageSize = $dynamicPageSize - $helpContentPageSize = $dynamicPageSize - $parameterListPageSize = $dynamicPageSize - - # Re-clamp scroll offsets - $commandListTotalItemsForClamp = $filteredCommandObjects.Count - if ($commandListTotalItemsForClamp -gt 0) { - $commandListScrollOffset = [System.Math]::Min($commandListScrollOffset, [System.Math]::Max(0, $commandListTotalItemsForClamp - $commandListPageSize)) - } else { $commandListScrollOffset = 0 } - - if ($commandParametersForHelp.Count -gt 0 -and $rightPaneView -eq 'ParameterList') { - $parameterListScrollOffset = [System.Math]::Min($parameterListScrollOffset, [System.Math]::Max(0, $commandParametersForHelp.Count - $parameterListPageSize)) - } else { $parameterListScrollOffset = 0 } - - if ($currentHelpContentLines.Count -gt 0) { - $helpContentScrollOffset = [System.Math]::Min($helpContentScrollOffset, [System.Math]::Max(0, $currentHelpContentLines.Count - $helpContentPageSize)) - } else { $helpContentScrollOffset = 0 } - } - - # Calculate dynamic maxNameLength for command list truncation - $_commandListPaneLayoutRatio = $commandListPaneLayout.Ratio - $_rightPaneLayoutRatio = $rightPaneLayout.Ratio - $_totalColumnRatiosInCombinedPanel = $_commandListPaneLayoutRatio + $_rightPaneLayoutRatio - - if ($_totalColumnRatiosInCombinedPanel -gt 0) { - $commandListPaneNominalWidth = [Math]::Floor($consoleWidth * ($_commandListPaneLayoutRatio / $_totalColumnRatiosInCombinedPanel)) - } else { - # Fallback if ratios are zero or not found, though they should be 1 and 3. - $commandListPaneNominalWidth = [Math]::Floor($consoleWidth / 4) # Default to 1/4 if ratios are problematic - } - - $panelHorizontalOverhead = 4 - $maxContentWidthInPanel = $commandListPaneNominalWidth - $panelHorizontalOverhead - $prefixCharsLength = 2 - $currentMaxNameLength = $maxContentWidthInPanel - $prefixCharsLength - if ($currentMaxNameLength -lt 10) {$currentMaxNameLength = 10} - $maxNameLength = $currentMaxNameLength - - # Filter command list based on search string - if ($searchString -ne "") { - $filteredCommandObjects = $allCommandObjects | Where-Object { $_.Name -like "*$searchString*" } - # Adjust current index and scroll if filter changes - if ($currentCommandIndex -ge $filteredCommandObjects.Count -and $filteredCommandObjects.Count -gt 0) { - $currentCommandIndex = $filteredCommandObjects.Count - 1 - } elseif ($filteredCommandObjects.Count -eq 0) { $currentCommandIndex = -1 } - - if ($currentCommandIndex -ne -1 -and ($currentCommandIndex -lt $commandListScrollOffset -or $currentCommandIndex -ge ($commandListScrollOffset + $commandListPageSize))) { - $commandListScrollOffset = [System.Math]::Max(0, $currentCommandIndex - [System.Math]::Floor($commandListPageSize / 2)) - } elseif ($currentCommandIndex -eq -1) { $commandListScrollOffset = 0 } - - $commandListTotalItemsForClampOnSearch = $filteredCommandObjects.Count - if ($commandListTotalItemsForClampOnSearch -gt 0) { - $commandListScrollOffset = [System.Math]::Min($commandListScrollOffset, [System.Math]::Max(0, $commandListTotalItemsForClampOnSearch - $commandListPageSize)) - } else { $commandListScrollOffset = 0 } - } else { - $filteredCommandObjects = $allCommandObjects - } - $commandListTotalItems = $filteredCommandObjects.Count - - # Ensure currentCommandIndex is valid after filtering or list change - if ($commandListTotalItems -eq 0) { - $currentCommandIndex = -1 - $commandListScrollOffset = 0 - } elseif ($currentCommandIndex -eq -1 -or $currentCommandIndex -ge $commandListTotalItems) { - # If previous selection is now invalid ( after clearing search) - $currentCommandIndex = 0 - $commandListScrollOffset = 0 - } - - # Command List Panel (Left Pane) - $listItems = New-Object System.Collections.Generic.List[string] - $commandListPanelHeader = "[bold]($($commandListTotalItems) total)[/]" - if ($searchString -ne "") { - $commandListPanelHeader += " Filter: [yellow]'$($searchString)'[/]" - } - - if ($commandListTotalItems -gt 0 -and $currentCommandIndex -ne -1) { - if ($commandListScrollOffset -gt 0) { $listItems.Add("[grey] ↑ ...[/]")} - - $visibleListStartIndex = $commandListScrollOffset - $visibleListEndIndex = [System.Math]::Min(($commandListScrollOffset + $commandListPageSize - 3), ($commandListTotalItems - 1)) - - for ($i = $visibleListStartIndex; $i -le $visibleListEndIndex; $i++) { - if ($i -lt 0 -or $i -ge $filteredCommandObjects.Count) { continue } # Boundary check - $cmd = $filteredCommandObjects[$i] - - $originalDisplayName = $cmd.Name - $processedDisplayName = $originalDisplayName - - if ($originalDisplayName.Length -gt $maxNameLength) { - if ($maxNameLength -ge 3) { - $processedDisplayName = $originalDisplayName.Substring(0, [Math]::Max(0, $maxNameLength - 3)) + "..." - } else { - $processedDisplayName = $originalDisplayName.Substring(0, [Math]::Max(0, $maxNameLength)) - } - } - - $styledName = switch ($cmd.Type) { - 'Cmdlet' { "[green]$processedDisplayName[/]" } - 'Function' { "[blue]$processedDisplayName[/]" } - 'Alias' { "[magenta]$processedDisplayName[/]" } - default { $processedDisplayName } - } - if ($i -eq $currentCommandIndex) { $listItems.Add("[yellow bold]>[/] $($styledName)") } - else { $listItems.Add(" $($styledName)") } - } - if ($visibleListEndIndex -lt ($commandListTotalItems - 1)) { $listItems.Add("[grey] ↓ ...[/]")} - } else { - $listItems.Add("[grey] (No commands to display) [/]") - } - - $commandListPanel = $listItems | Format-SpectreRows | Format-SpectrePanel -Header $commandListPanelHeader -Expand -Border Rounded - $layout["commandListPane"].Update($commandListPanel) | Out-Null - - - # Right Pane for Command View (Description, Help Options, ParameterList, or Help Content) - $rightPanelContentRenderable = $null - $rightPanelHeader = "[bold]Info[/]" - - if ($rightPaneView -eq 'Description') { - $rightPanelHeader = "[bold]Description[/]" - if ($currentCommandIndex -ge 0 -and $currentCommandIndex -lt $filteredCommandObjects.Count) { - $currentCmdForDesc = $filteredCommandObjects[$currentCommandIndex] - $rightPanelHeader = "[bold]Description for $($currentCmdForDesc.Name)[/]" - $descriptionText = if ($currentCmdForDesc.Synopsis -and $currentCmdForDesc.Synopsis -ne "") { - $currentCmdForDesc.Synopsis - } elseif ($currentCmdForDesc.Type -eq 'Alias' -and $currentCmdForDesc.Definition) { - "Alias for: $($currentCmdForDesc.Definition)" - } else { "[grey]No synopsis available.[/]" } - $rightPanelContentRenderable = ($descriptionText | Get-SpectreEscapedText | Format-SpectrePanel -Header $rightPanelHeader -Expand -Border Rounded) - } else { - $rightPanelContentRenderable = (Write-SpectreHost "[grey]No command selected or found.[/]" -PassThru | Format-SpectrePanel -Header $rightPanelHeader -Expand -Border Rounded) - } - } elseif ($rightPaneView -eq 'HelpOptions') { - $rightPanelHeader = "[bold]Help Options for $($currentCommandObjectForHelp.Name)[/]" - $helpOptionListItems = for ($i = 0; $i -lt $helpOptions.Count; $i++) { - if ($i -eq $currentHelpOptionIndex) { "[yellow bold]>[/] $($helpOptions[$i])" } - else { " $($helpOptions[$i])" } - } - $rightPanelContentRenderable = ($helpOptionListItems | Format-SpectreRows | Format-SpectrePanel -Header $rightPanelHeader -Expand -Border Rounded) - - } elseif ($rightPaneView -eq 'ParameterList') { - $rightPanelHeader = "[bold]Parameters for $($currentCommandObjectForHelp.Name)[/]" - $paramListItems = New-Object System.Collections.Generic.List[string] - if ($commandParametersForHelp.Count -gt 0) { - if ($parameterListScrollOffset -gt 0) { $paramListItems.Add("[grey] ↑ ...[/]")} - - $visibleParamListStartIndex = $parameterListScrollOffset - $visibleParamListEndIndex = [System.Math]::Min(($parameterListScrollOffset + $parameterListPageSize - 1), ($commandParametersForHelp.Count - 1)) - - for ($p = $visibleParamListStartIndex; $p -le $visibleParamListEndIndex; $p++) { - if ($p -lt 0 -or $p -ge $commandParametersForHelp.Count) { continue } - $paramMetadata = $commandParametersForHelp[$p] - $paramName = $paramMetadata.Name - $styledParamName = if ($commonParameterNames -contains $paramName) { - "[grey]$paramName[/]" - } else { - $paramName - } - # Use $currentParameterIndex for highlighting in this view - if ($p -eq $currentParameterIndex) { $paramListItems.Add("[yellow bold]>[/] $($styledParamName)") } - else { $paramListItems.Add(" $($styledParamName)") } - } - if ($visibleParamListEndIndex -lt ($commandParametersForHelp.Count - 1)) { $paramListItems.Add("[grey] ↓ ...[/]")} - } else { - $paramListItems.Add("[grey](No parameters found or command does not support parameters)[/]") - } - $rightPanelContentRenderable = ($paramListItems | Format-SpectreRows | Format-SpectrePanel -Header $rightPanelHeader -Expand -Border Rounded) - - } elseif ($rightPaneView -eq 'HelpContent' -or $rightPaneView -eq 'ParameterHelpContent') { - # Determine header based on whether it's general help or parameter-specific help - if ($rightPaneView -eq 'ParameterHelpContent') { - $rightPanelHeader = "[bold]Parameter: $($currentParameterObjectForHelp.Name) in $($currentCommandObjectForHelp.Name)[/]" - } else { # HelpContent - $rightPanelHeader = "[bold]Help: $($currentCommandObjectForHelp.Name) - $($helpOptions[$currentHelpOptionIndex])[/]" - } - - # Display scrolled content - $visibleHelpLines = New-Object System.Collections.Generic.List[string] - if ($currentHelpContentLines.Count -gt 0) { - if ($helpContentScrollOffset -gt 0) { $visibleHelpLines.Add("[grey] ↑ ...[/]")} - - $helpViewEndIndex = [System.Math]::Min(($helpContentScrollOffset + $helpContentPageSize - 1), ($currentHelpContentLines.Count - 1)) - for ($l = $helpContentScrollOffset; $l -le $helpViewEndIndex; $l++) { - if ($l -ge 0 -and $l -lt $currentHelpContentLines.Count) { - $visibleHelpLines.Add($currentHelpContentLines[$l]) - } - } - - if ($helpViewEndIndex -lt ($currentHelpContentLines.Count - 1)) { $visibleHelpLines.Add("[grey] ↓ ...[/]")} - } else { - $visibleHelpLines.Add("[grey](No help content available for this view)[/]") - } - $rightPanelContentRenderable = ($visibleHelpLines | Format-SpectreRows | Format-SpectrePanel -Header $rightPanelHeader -Expand -Border Rounded) - } - - $layout["rightPane"].Update($rightPanelContentRenderable) | Out-Null - + # Recalculate page sizes on console resize + $newPageSize = [Math]::Max( + $script:UIConfig.Layout.MinPageSize, + $Host.UI.RawUI.WindowSize.Height - $script:UIConfig.Layout.FixedRowsOverhead + ) + if ($newPageSize -ne $state.CommandListPageSize) { + $state.CommandListPageSize = $newPageSize + $state.HelpContentPageSize = $newPageSize + $state.ParameterListPageSize = $newPageSize + } + + Update-ViewerLayout -State $state -Layout $layout $LiveContext.Refresh() - # Input handling if (-not [Console]::KeyAvailable) { Start-Sleep -Milliseconds 50; continue } $keyInfo = [Console]::ReadKey($true) if ($keyInfo.Key -eq [System.ConsoleKey]::Escape) { return $null } - # Type to Search but only when in Description view - if ($rightPaneView -eq 'Description' -and - (($keyInfo.KeyChar -ge 'a' -and $keyInfo.KeyChar -le 'z') -or - ($keyInfo.KeyChar -ge 'A' -and $keyInfo.KeyChar -le 'Z') -or - ($keyInfo.KeyChar -ge '0' -and $keyInfo.KeyChar -le '9') -or - $keyInfo.KeyChar -eq '-' -or $keyInfo.KeyChar -eq '_' ) ) { - $searchString += $keyInfo.KeyChar - $currentCommandIndex = 0 - $commandListScrollOffset = 0 - continue # Re-render with new search - } - - # More Input Handling! - if ($rightPaneView -eq 'Description') { - switch ($keyInfo.Key) { - ([System.ConsoleKey]::UpArrow) { - if ($currentCommandIndex -gt 0) { - $currentCommandIndex-- - if ($currentCommandIndex -lt $commandListScrollOffset) { - $commandListScrollOffset = $currentCommandIndex # Snap to top - } - } - } - ([System.ConsoleKey]::DownArrow) { - if ($commandListTotalItems -gt 0 -and $currentCommandIndex -lt ($commandListTotalItems - 1)) { - $currentCommandIndex++ - if ($currentCommandIndex -ge ($commandListScrollOffset + $commandListPageSize - 2)) { # -2 to offset to account for the scroll indicators - $commandListScrollOffset++ # Scroll down one line - } - } - } - ([System.ConsoleKey]::RightArrow) { - if ($currentCommandIndex -ge 0 -and $currentCommandIndex -lt $filteredCommandObjects.Count) { - $currentCommandObjectForHelp = $filteredCommandObjects[$currentCommandIndex] - $rightPaneView = 'HelpOptions' - $currentHelpOptionIndex = 0 - $searchString = "" # Clear search when moving to help - } - } - ([System.ConsoleKey]::LeftArrow) { # Backspace for search or exit - if ($searchString.Length -gt 0) { - $searchString = $searchString.Substring(0, $searchString.Length - 1) - $currentCommandIndex = 0 - $commandListScrollOffset = 0 - } else { return $null } - } - ([System.ConsoleKey]::Backspace) { - if ($searchString.Length -gt 0) { - $searchString = $searchString.Substring(0, $searchString.Length - 1) - $currentCommandIndex = 0 - $commandListScrollOffset = 0 - } - } - ([System.ConsoleKey]::Enter) { # Same as RightArrow - if ($currentCommandIndex -ge 0 -and $currentCommandIndex -lt $filteredCommandObjects.Count) { - $currentCommandObjectForHelp = $filteredCommandObjects[$currentCommandIndex] - $rightPaneView = 'HelpOptions' - $currentHelpOptionIndex = 0 - $searchString = "" - } - } - } - } elseif ($rightPaneView -eq 'HelpOptions') { - switch ($keyInfo.Key) { - ([System.ConsoleKey]::UpArrow) { - if ($currentHelpOptionIndex -gt 0) { $currentHelpOptionIndex-- } - } - ([System.ConsoleKey]::DownArrow) { - if ($currentHelpOptionIndex -lt ($helpOptions.Count - 1)) { $currentHelpOptionIndex++ } - } - ([System.ConsoleKey]::RightArrow) { - $selectedHelpType = $helpOptions[$currentHelpOptionIndex] - $currentHelpContentLines = @("[grey]Fetching help...[/]") - $helpContentScrollOffset = 0 - - if ($selectedHelpType -eq "Parameters") { - $allParamsCollection = $currentCommandObjectForHelp.CommandInfo.Parameters.Values - $tempParameterList = New-Object System.Collections.Generic.List[System.Management.Automation.ParameterMetadata] - - $nonCommonParamsOutput = [System.Management.Automation.ParameterMetadata[]]( - $allParamsCollection | Where-Object { $commonParameterNames -notcontains $_.Name } | Sort-Object Name - ) - if ($nonCommonParamsOutput) { - $tempParameterList.AddRange($nonCommonParamsOutput) - } - - $commonParamsFromCmdOutput = [System.Management.Automation.ParameterMetadata[]]( - $allParamsCollection | Where-Object { $commonParameterNames -contains $_.Name } | Sort-Object Name - ) - if ($commonParamsFromCmdOutput) { - $tempParameterList.AddRange($commonParamsFromCmdOutput) - } - $commandParametersForHelp = $tempParameterList.ToArray() - $currentParameterIndex = 0 - $parameterListScrollOffset = 0 - $rightPaneView = 'ParameterList' - } elseif ($selectedHelpType -eq "Online") { - $currentHelpContentLines = @("[yellow]Press Right Arrow or Enter to open online help (if available), or Left Arrow to go back.[/]") - $rightPaneView = 'HelpContent' - } else { - $rightPaneView = 'HelpContent' - $LiveContext.Refresh() - try { - $helpText = "" - switch($selectedHelpType) { - "Examples" { $helpText = Get-Help $currentCommandObjectForHelp.CommandInfo -Examples | Out-String } - "Detailed" { $helpText = Get-Help $currentCommandObjectForHelp.CommandInfo -Detailed | Out-String } - "Full" { $helpText = Get-Help $currentCommandObjectForHelp.CommandInfo -Full | Out-String } - } - $currentHelpContentLines = ($helpText | Get-SpectreEscapedText) -split "`r?`n" - if ($currentHelpContentLines.Count -eq 0 -or ($currentHelpContentLines.Count -eq 1 -and [string]::IsNullOrWhiteSpace($currentHelpContentLines[0]))) { - $currentHelpContentLines = @("[grey](No content for this help type)[/]") - } - } catch { - $currentHelpContentLines = @(("[red]Could not retrieve help: $($_.Exception.Message | Get-SpectreEscapedText)[/]" -split "`r?`n")) - } - } - } - ([System.ConsoleKey]::LeftArrow) { # Go back to Description view - $rightPaneView = 'Description' - $currentCommandObjectForHelp = $null - $currentHelpContentLines = @(); $helpContentScrollOffset = 0 - } - ([System.ConsoleKey]::Enter) { # Same as RightArrow - $selectedHelpType = $helpOptions[$currentHelpOptionIndex] - $currentHelpContentLines = @("[grey]Fetching help...[/]") - $helpContentScrollOffset = 0 - - if ($selectedHelpType -eq "Parameters") { - $allParamsCollection = $currentCommandObjectForHelp.CommandInfo.Parameters.Values - $tempParameterList = New-Object System.Collections.Generic.List[System.Management.Automation.ParameterMetadata] - - $nonCommonParamsOutput = [System.Management.Automation.ParameterMetadata[]]( - $allParamsCollection | Where-Object { $commonParameterNames -notcontains $_.Name } | Sort-Object Name - ) - if ($nonCommonParamsOutput) { - $tempParameterList.AddRange($nonCommonParamsOutput) - } - - $commonParamsFromCmdOutput = [System.Management.Automation.ParameterMetadata[]]( - $allParamsCollection | Where-Object { $commonParameterNames -contains $_.Name } | Sort-Object Name - ) - if ($commonParamsFromCmdOutput) { - $tempParameterList.AddRange($commonParamsFromCmdOutput) - } - $commandParametersForHelp = $tempParameterList.ToArray() - $currentParameterIndex = 0 - $parameterListScrollOffset = 0 - $rightPaneView = 'ParameterList' - } elseif ($selectedHelpType -eq "Online") { - $currentHelpContentLines = @("[yellow]Press Right Arrow or Enter to open online help (if available), or Left Arrow to go back.[/]") - $rightPaneView = 'HelpContent' - } else { - $rightPaneView = 'HelpContent' - $LiveContext.Refresh() - try { - $helpText = "" - switch($selectedHelpType) { - "Examples" { $helpText = Get-Help $currentCommandObjectForHelp.CommandInfo -Examples | Out-String } - "Detailed" { $helpText = Get-Help $currentCommandObjectForHelp.CommandInfo -Detailed | Out-String } - "Full" { $helpText = Get-Help $currentCommandObjectForHelp.CommandInfo -Full | Out-String } - } - $currentHelpContentLines = ($helpText | Get-SpectreEscapedText) -split "`r?`n" - if ($currentHelpContentLines.Count -eq 0 -or ($currentHelpContentLines.Count -eq 1 -and [string]::IsNullOrWhiteSpace($currentHelpContentLines[0]))) { - $currentHelpContentLines = @("[grey](No content for this help type)[/]") - } - } catch { - $currentHelpContentLines = @(("[red]Could not retrieve help: $($_.Exception.Message | Get-SpectreEscapedText)[/]" -split "`r?`n")) - } - } - } - } - } elseif ($rightPaneView -eq 'ParameterList') { - switch ($keyInfo.Key) { - ([System.ConsoleKey]::UpArrow) { - if ($currentParameterIndex -gt 0) { - $currentParameterIndex-- - if ($currentParameterIndex -lt $parameterListScrollOffset) { - $parameterListScrollOffset = $currentParameterIndex - } - } - } - ([System.ConsoleKey]::DownArrow) { - if ($commandParametersForHelp.Count -gt 0 -and $currentParameterIndex -lt ($commandParametersForHelp.Count - 1)) { - $currentParameterIndex++ - if ($currentParameterIndex -ge ($parameterListScrollOffset + $parameterListPageSize)) { - $parameterListScrollOffset++ - } - } - } - ([System.ConsoleKey]::RightArrow) { - if ($commandParametersForHelp.Count -gt 0 -and $currentParameterIndex -ge 0 -and $currentParameterIndex -lt $commandParametersForHelp.Count) { - $currentParameterObjectForHelp = $commandParametersForHelp[$currentParameterIndex] # This is ParameterMetadata - $currentHelpContentLines = @("[grey]Fetching parameter help...[/]") - $helpContentScrollOffset = 0 - $rightPaneView = 'ParameterHelpContent' - $LiveContext.Refresh() - try { - $paramHelpText = Get-Help $currentCommandObjectForHelp.Name -Parameter $currentParameterObjectForHelp.Name | Out-String - if ([string]::IsNullOrWhiteSpace($paramHelpText) -and $currentParameterObjectForHelp.HelpMessage) { # Fallback - $paramHelpText = $currentParameterObjectForHelp.HelpMessage - } - - if (-not [string]::IsNullOrWhiteSpace($paramHelpText)) { - $currentHelpContentLines = ($paramHelpText | Get-SpectreEscapedText) -split "`r?`n" - } else { - $currentHelpContentLines = @("[grey](No specific help message found for this parameter.)[/]") - } - } catch { - $currentHelpContentLines = @(("[red]Could not retrieve help for parameter '$($currentParameterObjectForHelp.Name)': $($_.Exception.Message | Get-SpectreEscapedText)[/]" -split "`r?`n")) - } - } - } - ([System.ConsoleKey]::LeftArrow) { # Go back to Help Options - $rightPaneView = 'HelpOptions' - $commandParametersForHelp = @() - $currentParameterObjectForHelp = $null - } - ([System.ConsoleKey]::Enter) { # Same as RightArrow - if ($commandParametersForHelp.Count -gt 0 -and $currentParameterIndex -ge 0 -and $currentParameterIndex -lt $commandParametersForHelp.Count) { - $currentParameterObjectForHelp = $commandParametersForHelp[$currentParameterIndex] - $currentHelpContentLines = @("[grey]Fetching parameter help...[/]") - $helpContentScrollOffset = 0 - $rightPaneView = 'ParameterHelpContent' - $LiveContext.Refresh() - try { - $paramHelpText = Get-Help $currentCommandObjectForHelp.Name -Parameter $currentParameterObjectForHelp.Name | Out-String - if ([string]::IsNullOrWhiteSpace($paramHelpText) -and $currentParameterObjectForHelp.HelpMessage) { - $paramHelpText = $currentParameterObjectForHelp.HelpMessage - } - if (-not [string]::IsNullOrWhiteSpace($paramHelpText)) { - $currentHelpContentLines = ($paramHelpText | Get-SpectreEscapedText) -split "`r?`n" - } else { - $currentHelpContentLines = @("[grey](No specific help message found for this parameter.)[/]") - } - } catch { - $currentHelpContentLines = @(("[red]Could not retrieve help for parameter '$($currentParameterObjectForHelp.Name)': $($_.Exception.Message | Get-SpectreEscapedText)[/]" -split "`r?`n")) - } - } - } - } - } elseif ($rightPaneView -eq 'HelpContent' -or $rightPaneView -eq 'ParameterHelpContent') { - switch ($keyInfo.Key) { - ([System.ConsoleKey]::LeftArrow) { # Go back to previous view - if ($rightPaneView -eq 'ParameterHelpContent') { - $rightPaneView = 'ParameterList' - } else { # HelpContent - $rightPaneView = 'HelpOptions' - } - $currentHelpContentLines = @(); $helpContentScrollOffset = 0 # Clear content - } - ([System.ConsoleKey]::RightArrow) { # Only for Online help - if ($rightPaneView -eq 'HelpContent' -and $helpOptions[$currentHelpOptionIndex] -eq "Online") { - try { Get-Help $currentCommandObjectForHelp.CommandInfo -Online } - catch { $currentHelpContentLines = @("[red]Could not retrieve online help. Press Left to go back.[/]") } - # Don't automatically go back, let user see message if it fails. - } - } - ([System.ConsoleKey]::Enter) { # Only for Online help - if ($rightPaneView -eq 'HelpContent' -and $helpOptions[$currentHelpOptionIndex] -eq "Online") { - try { Get-Help $currentCommandObjectForHelp.CommandInfo -Online } - catch { $currentHelpContentLines = @("[red]Could not retrieve online help. Press Left to go back.[/]") } - } - } - ([System.ConsoleKey]::UpArrow) { # Scroll up - if ($helpContentScrollOffset -gt 0) { $helpContentScrollOffset-- } - } - ([System.ConsoleKey]::DownArrow) { # Scroll down - if (($helpContentScrollOffset + $helpContentPageSize) -lt $currentHelpContentLines.Count) { - $helpContentScrollOffset++ - } - } - } - } - } # End while ($true) + $result = Invoke-ViewerInputHandler -KeyInfo $keyInfo -State $state + if ($null -eq $result) { return $null } + } } catch { - # Catch any unexpected errors during the live display Write-SpectreHost "[bold red]Error within Invoke-SpectreLive: $($_.Exception.ToString() | Get-SpectreEscapedText)[/]" - Read-SpectrePause -Message "[grey]Press Enter to acknowledge error and return...[/]" -NoNewline + Read-SpectrePause -Message '[grey]Press Enter to acknowledge error and return...[/]' -NoNewline return $null } - } # End Invoke-SpectreLive ScriptBlock + } - Clear-Host # Clean up the console after exiting the live display -} \ No newline at end of file + Clear-Host +} diff --git a/ModuleExplorer/internal/functions/Get-ScrollableListView.ps1 b/ModuleExplorer/internal/functions/Get-ScrollableListView.ps1 index 0f65d4b..d754f2d 100644 --- a/ModuleExplorer/internal/functions/Get-ScrollableListView.ps1 +++ b/ModuleExplorer/internal/functions/Get-ScrollableListView.ps1 @@ -65,11 +65,14 @@ function Get-ScrollableListView { $result = [System.Collections.Generic.List[string]]::new() - if ($Items.Count -eq 0 -or $SelectedIndex -lt 0) { + if ($Items.Count -eq 0) { $result.Add('[grey](No items to display)[/]') return $result.ToArray() } + # SelectedIndex = -1 means render without any selection highlight + if ($SelectedIndex -lt 0) { $SelectedIndex = -1 } + # Scroll-indicator rows consume 1 row each; reserve slots for them so that # the visible content area never exceeds PageSize total rows. $hasUp = $ScrollOffset -gt 0 diff --git a/ModuleExplorer/internal/functions/Invoke-ViewerInputHandler.ps1 b/ModuleExplorer/internal/functions/Invoke-ViewerInputHandler.ps1 new file mode 100644 index 0000000..48dc5a4 --- /dev/null +++ b/ModuleExplorer/internal/functions/Invoke-ViewerInputHandler.ps1 @@ -0,0 +1,221 @@ +<# +.SYNOPSIS + Routes a console key press to the correct handler for the viewer's current state. + +.DESCRIPTION + Invoke-ViewerInputHandler is the central input dispatcher for Show-ModuleCommandViewer. + It examines $State.View to determine the active view and dispatches the key press to + the appropriate per-view logic. All state changes are made directly on the $State + hashtable (a reference type), so callers see mutations immediately after the call. + + Returns $null to signal the viewer should exit; returns $true to continue the loop. + +.PARAMETER KeyInfo + The ConsoleKeyInfo captured by [Console]::ReadKey(). + +.PARAMETER State + Mutable viewer state hashtable. Expected keys: View, CommandIndex, + CommandListScrollOffset, CommandListPageSize, FilteredCommands, SearchString, + HelpOptionIndex, HelpOptions, ParameterIndex, ParameterListScrollOffset, + ParameterListPageSize, Parameters, CurrentCommand, CurrentParameter, + HelpContentLines, HelpContentScrollOffset, HelpContentPageSize, LiveContext. + +.OUTPUTS + $null — caller should exit the viewer loop. + $true — caller should continue the viewer loop. +#> +function Invoke-ViewerInputHandler { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.ConsoleKeyInfo]$KeyInfo, + + [Parameter(Mandatory)] + [hashtable]$State + ) + + # Type-to-search: alphanumeric, hyphen, underscore — Description view only + if ($State.View -eq 'Description' -and + (($KeyInfo.KeyChar -ge 'a' -and $KeyInfo.KeyChar -le 'z') -or + ($KeyInfo.KeyChar -ge 'A' -and $KeyInfo.KeyChar -le 'Z') -or + ($KeyInfo.KeyChar -ge '0' -and $KeyInfo.KeyChar -le '9') -or + $KeyInfo.KeyChar -eq '-' -or $KeyInfo.KeyChar -eq '_')) { + $State.SearchString += $KeyInfo.KeyChar + $State.CommandIndex = 0 + $State.CommandListScrollOffset = 0 + return $true + } + + switch ($State.View) { + + 'Description' { + switch ($KeyInfo.Key) { + ([System.ConsoleKey]::UpArrow) { + if ($State.CommandIndex -gt 0) { + $State.CommandIndex-- + if ($State.CommandIndex -lt $State.CommandListScrollOffset) { + $State.CommandListScrollOffset = $State.CommandIndex + } + } + } + ([System.ConsoleKey]::DownArrow) { + $total = $State.FilteredCommands.Count + if ($total -gt 0 -and $State.CommandIndex -lt ($total - 1)) { + $State.CommandIndex++ + # -2 accounts for both scroll indicator rows + if ($State.CommandIndex -ge ($State.CommandListScrollOffset + $State.CommandListPageSize - 2)) { + $State.CommandListScrollOffset++ + } + } + } + ([System.ConsoleKey]::RightArrow) { + if ($State.CommandIndex -ge 0 -and $State.CommandIndex -lt $State.FilteredCommands.Count) { + $State.CurrentCommand = $State.FilteredCommands[$State.CommandIndex] + $State.View = 'HelpOptions' + $State.HelpOptionIndex = 0 + $State.SearchString = '' + } + } + ([System.ConsoleKey]::Enter) { + if ($State.CommandIndex -ge 0 -and $State.CommandIndex -lt $State.FilteredCommands.Count) { + $State.CurrentCommand = $State.FilteredCommands[$State.CommandIndex] + $State.View = 'HelpOptions' + $State.HelpOptionIndex = 0 + $State.SearchString = '' + } + } + ([System.ConsoleKey]::LeftArrow) { + if ($State.SearchString.Length -gt 0) { + $State.SearchString = $State.SearchString.Substring(0, $State.SearchString.Length - 1) + $State.CommandIndex = 0 + $State.CommandListScrollOffset = 0 + } else { + return $null + } + } + ([System.ConsoleKey]::Backspace) { + if ($State.SearchString.Length -gt 0) { + $State.SearchString = $State.SearchString.Substring(0, $State.SearchString.Length - 1) + $State.CommandIndex = 0 + $State.CommandListScrollOffset = 0 + } + } + } + } + + 'HelpOptions' { + switch ($KeyInfo.Key) { + ([System.ConsoleKey]::UpArrow) { + if ($State.HelpOptionIndex -gt 0) { $State.HelpOptionIndex-- } + } + ([System.ConsoleKey]::DownArrow) { + if ($State.HelpOptionIndex -lt ($State.HelpOptions.Count - 1)) { $State.HelpOptionIndex++ } + } + ([System.ConsoleKey]::RightArrow) { Invoke-ViewerHelpOptionSelect -State $State } + ([System.ConsoleKey]::Enter) { Invoke-ViewerHelpOptionSelect -State $State } + ([System.ConsoleKey]::LeftArrow) { + $State.View = 'Description' + $State.CurrentCommand = $null + $State.HelpContentLines = @() + $State.HelpContentScrollOffset = 0 + } + } + } + + 'ParameterList' { + switch ($KeyInfo.Key) { + ([System.ConsoleKey]::UpArrow) { + if ($State.ParameterIndex -gt 0) { + $State.ParameterIndex-- + if ($State.ParameterIndex -lt $State.ParameterListScrollOffset) { + $State.ParameterListScrollOffset = $State.ParameterIndex + } + } + } + ([System.ConsoleKey]::DownArrow) { + if ($State.Parameters.Count -gt 0 -and $State.ParameterIndex -lt ($State.Parameters.Count - 1)) { + $State.ParameterIndex++ + if ($State.ParameterIndex -ge ($State.ParameterListScrollOffset + $State.ParameterListPageSize)) { + $State.ParameterListScrollOffset++ + } + } + } + ([System.ConsoleKey]::RightArrow) { Invoke-ViewerParameterSelect -State $State } + ([System.ConsoleKey]::Enter) { Invoke-ViewerParameterSelect -State $State } + ([System.ConsoleKey]::LeftArrow) { + $State.View = 'HelpOptions' + $State.Parameters = @() + $State.CurrentParameter = $null + } + } + } + + { $_ -in 'HelpContent', 'ParameterHelpContent' } { + switch ($KeyInfo.Key) { + ([System.ConsoleKey]::LeftArrow) { + $State.View = if ($State.View -eq 'ParameterHelpContent') { 'ParameterList' } else { 'HelpOptions' } + $State.HelpContentLines = @() + $State.HelpContentScrollOffset = 0 + } + ([System.ConsoleKey]::RightArrow) { + if ($State.HelpOptions[$State.HelpOptionIndex] -eq 'Online') { + try { Get-Help $State.CurrentCommand.CommandInfo -Online } + catch { $State.HelpContentLines = @('[red]Could not retrieve online help. Press Left to go back.[/]') } + } + } + ([System.ConsoleKey]::Enter) { + if ($State.HelpOptions[$State.HelpOptionIndex] -eq 'Online') { + try { Get-Help $State.CurrentCommand.CommandInfo -Online } + catch { $State.HelpContentLines = @('[red]Could not retrieve online help. Press Left to go back.[/]') } + } + } + ([System.ConsoleKey]::UpArrow) { + if ($State.HelpContentScrollOffset -gt 0) { $State.HelpContentScrollOffset-- } + } + ([System.ConsoleKey]::DownArrow) { + if (($State.HelpContentScrollOffset + $State.HelpContentPageSize) -lt $State.HelpContentLines.Count) { + $State.HelpContentScrollOffset++ + } + } + } + } + } + + return $true +} + +function Invoke-ViewerHelpOptionSelect { + param([hashtable]$State) + $selectedHelpType = $State.HelpOptions[$State.HelpOptionIndex] + $State.HelpContentLines = @('[grey]Fetching help...[/]') + $State.HelpContentScrollOffset = 0 + + if ($selectedHelpType -eq 'Parameters') { + $State.Parameters = Get-SortedParameterList -Parameters $State.CurrentCommand.CommandInfo.Parameters.Values + $State.ParameterIndex = 0 + $State.ParameterListScrollOffset = 0 + $State.View = 'ParameterList' + } else { + $State.View = 'HelpContent' + $State.LiveContext.Refresh() + $State.HelpContentLines = Get-CommandHelpContent -HelpType $selectedHelpType -CommandInfo $State.CurrentCommand.CommandInfo + } +} + +function Invoke-ViewerParameterSelect { + param([hashtable]$State) + if ($State.Parameters.Count -gt 0 -and + $State.ParameterIndex -ge 0 -and + $State.ParameterIndex -lt $State.Parameters.Count) { + + $State.CurrentParameter = $State.Parameters[$State.ParameterIndex] + $State.HelpContentLines = @('[grey]Fetching parameter help...[/]') + $State.HelpContentScrollOffset = 0 + $State.View = 'ParameterHelpContent' + $State.LiveContext.Refresh() + $State.HelpContentLines = Get-ParameterHelpContent ` + -CommandName $State.CurrentCommand.Name ` + -ParameterName $State.CurrentParameter.Name ` + -FallbackHelpMessage $State.CurrentParameter.HelpMessage + } +} diff --git a/ModuleExplorer/internal/functions/Update-ViewerLayout.ps1 b/ModuleExplorer/internal/functions/Update-ViewerLayout.ps1 new file mode 100644 index 0000000..691fad9 --- /dev/null +++ b/ModuleExplorer/internal/functions/Update-ViewerLayout.ps1 @@ -0,0 +1,173 @@ +<# +.SYNOPSIS + Renders the current viewer state into the live Spectre Console layout. + +.DESCRIPTION + Update-ViewerLayout centralizes all rendering logic for Show-ModuleCommandViewer. + On every call it: + 1. Re-applies the search filter to produce State.FilteredCommands. + 2. Clamps navigation indices so they stay within bounds. + 3. Recalculates MaxNameLength from the current console width. + 4. Rebuilds the command-list panel (left pane). + 5. Rebuilds the right pane for whichever view is active: + Description | HelpOptions | ParameterList | HelpContent | ParameterHelpContent. + + Both pane updates are written directly into the $Layout object; the caller is + responsible for calling $LiveContext.Refresh() afterwards. + +.PARAMETER State + The mutable viewer state hashtable produced by Show-ModuleCommandViewer. + +.PARAMETER Layout + The root Spectre Console layout object. Must have named children + 'commandListPane' and 'rightPane'. +#> +function Update-ViewerLayout { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$State, + + [Parameter(Mandatory)] + $Layout + ) + + $cfg = $script:UIConfig + + # --- Filtering ----------------------------------------------------------- + if ($State.SearchString -ne '') { + $State.FilteredCommands = @($State.AllCommands | Where-Object { $_.Name -like "*$($State.SearchString)*" }) + } else { + $State.FilteredCommands = $State.AllCommands + } + $total = $State.FilteredCommands.Count + + if ($total -eq 0) { + $State.CommandIndex = -1 + $State.CommandListScrollOffset = 0 + } elseif ($State.CommandIndex -lt 0 -or $State.CommandIndex -ge $total) { + $State.CommandIndex = 0 + $State.CommandListScrollOffset = 0 + } + + # --- MaxNameLength ------------------------------------------------------- + $consoleWidth = $Host.UI.RawUI.WindowSize.Width + $totalRatio = $cfg.Layout.CommandPaneRatio + $cfg.Layout.RightPaneRatio + $paneWidth = if ($totalRatio -gt 0) { + [Math]::Floor($consoleWidth * ($cfg.Layout.CommandPaneRatio / $totalRatio)) + } else { [Math]::Floor($consoleWidth / 4) } + $maxNameLen = $paneWidth - 4 - 2 # 4 for panel borders, 2 for selector prefix + if ($maxNameLen -lt $cfg.Layout.MinNameLength) { $maxNameLen = $cfg.Layout.MinNameLength } + $State.MaxNameLength = $maxNameLen + + # --- Command List Panel -------------------------------------------------- + $listHeader = "[bold]($total total)[/]" + if ($State.SearchString -ne '') { + $listHeader += " Filter: [yellow]'$($State.SearchString | Get-SpectreEscapedText)'[/]" + } + + if ($total -gt 0 -and $State.CommandIndex -ge 0) { + $formattedCmds = @($State.FilteredCommands | ForEach-Object { + $n = $_.Name + if ($n.Length -gt $State.MaxNameLength) { + $n = $n.Substring(0, [Math]::Max(0, $State.MaxNameLength - 3)) + '...' + } + switch ($_.Type) { + 'Cmdlet' { "[$($cfg.Colors.Cmdlet)]$n[/]" } + 'Function' { "[$($cfg.Colors.Function)]$n[/]" } + 'Alias' { "[$($cfg.Colors.Alias)]$n[/]" } + default { $n } + } + }) + + $cmdLines = Get-ScrollableListView ` + -Items $formattedCmds ` + -SelectedIndex $State.CommandIndex ` + -ScrollOffset $State.CommandListScrollOffset ` + -PageSize $State.CommandListPageSize + } else { + $cmdLines = @('[grey] (No commands to display) [/]') + } + + $cmdPanel = $cmdLines | Format-SpectreRows | Format-SpectrePanel -Header $listHeader -Expand -Border Rounded + $Layout['commandListPane'].Update($cmdPanel) | Out-Null + + # --- Right Pane ---------------------------------------------------------- + $rightRenderable = $null + + switch ($State.View) { + + 'Description' { + $rightHeader = '[bold]Description[/]' + if ($State.CommandIndex -ge 0 -and $State.CommandIndex -lt $State.FilteredCommands.Count) { + $cmd = $State.FilteredCommands[$State.CommandIndex] + $rightHeader = "[bold]Description for $($cmd.Name)[/]" + $descText = if ($cmd.Synopsis -and $cmd.Synopsis -ne '') { + $cmd.Synopsis + } elseif ($cmd.Type -eq 'Alias' -and $cmd.Definition) { + "Alias for: $($cmd.Definition)" + } else { + '[grey]No synopsis available.[/]' + } + $rightRenderable = ($descText | Get-SpectreEscapedText) | + Format-SpectrePanel -Header $rightHeader -Expand -Border Rounded + } else { + $rightRenderable = Write-SpectreHost '[grey]No command selected or found.[/]' -PassThru | + Format-SpectrePanel -Header $rightHeader -Expand -Border Rounded + } + } + + 'HelpOptions' { + $rightHeader = "[bold]Help Options for $($State.CurrentCommand.Name)[/]" + $optionLines = for ($i = 0; $i -lt $State.HelpOptions.Count; $i++) { + if ($i -eq $State.HelpOptionIndex) { "[$($cfg.Colors.Highlight)]>[/] $($State.HelpOptions[$i])" } + else { " $($State.HelpOptions[$i])" } + } + $rightRenderable = $optionLines | + Format-SpectreRows | Format-SpectrePanel -Header $rightHeader -Expand -Border Rounded + } + + 'ParameterList' { + $rightHeader = "[bold]Parameters for $($State.CurrentCommand.Name)[/]" + if ($State.Parameters.Count -gt 0) { + $formattedParams = @($State.Parameters | ForEach-Object { + if ($cfg.CommonParameters -contains $_.Name) { + "[$($cfg.Colors.Muted)]$($_.Name)[/]" + } else { + $_.Name + } + }) + $paramLines = Get-ScrollableListView ` + -Items $formattedParams ` + -SelectedIndex $State.ParameterIndex ` + -ScrollOffset $State.ParameterListScrollOffset ` + -PageSize $State.ParameterListPageSize + } else { + $paramLines = @('[grey](No parameters found or command does not support parameters)[/]') + } + $rightRenderable = $paramLines | + Format-SpectreRows | Format-SpectrePanel -Header $rightHeader -Expand -Border Rounded + } + + { $_ -in 'HelpContent', 'ParameterHelpContent' } { + $rightHeader = if ($State.View -eq 'ParameterHelpContent') { + "[bold]Parameter: $($State.CurrentParameter.Name) in $($State.CurrentCommand.Name)[/]" + } else { + "[bold]Help: $($State.CurrentCommand.Name) - $($State.HelpOptions[$State.HelpOptionIndex])[/]" + } + if ($State.HelpContentLines.Count -gt 0) { + $helpLines = Get-ScrollableListView ` + -Items $State.HelpContentLines ` + -SelectedIndex -1 ` + -ScrollOffset $State.HelpContentScrollOffset ` + -PageSize $State.HelpContentPageSize + } else { + $helpLines = @('[grey](No help content available for this view)[/]') + } + $rightRenderable = $helpLines | + Format-SpectreRows | Format-SpectrePanel -Header $rightHeader -Expand -Border Rounded + } + } + + $Layout['rightPane'].Update($rightRenderable) | Out-Null +} From f9f1e303263eb0d84e0bc23aec20b37834dc92a5 Mon Sep 17 00:00:00 2001 From: DearingDev Date: Fri, 17 Apr 2026 13:12:02 -0700 Subject: [PATCH 4/6] Moved to data provider module --- .../functions/Show-ModuleCommandViewer.ps1 | 43 ++------ .../functions/Show-ModuleExplorer.ps1 | 3 +- .../functions/Invoke-ViewerInputHandler.ps1 | 15 ++- .../functions/New-ModuleDataProvider.ps1 | 101 ++++++++++++++++++ 4 files changed, 119 insertions(+), 43 deletions(-) create mode 100644 ModuleExplorer/internal/functions/New-ModuleDataProvider.ps1 diff --git a/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 b/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 index 6862e01..f54c79f 100644 --- a/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 +++ b/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 @@ -93,45 +93,19 @@ function Show-ModuleCommandViewer { param ( [Parameter(Mandatory)] - [PSObject]$SelectedModule - ) + [PSObject]$SelectedModule, - $commands = Get-Command -Module $SelectedModule.Name | Sort-Object CommandType, Name + [hashtable]$DataProvider + ) - if (-not $commands) { - Write-SpectreHost "[yellow]No exported commands found for module '$($SelectedModule.Name)'.[/]" - Read-SpectrePause -Message "[grey]Press Enter to continue...[/]" -NoNewline - return + if (-not $DataProvider) { + $DataProvider = New-ModuleDataProvider -ModuleName $SelectedModule.Name } - # Pre-fetch command details including synopsis - $allCommandObjects = @($commands | ForEach-Object { - $synopsis = "" - try { - $helpInfo = Get-Help $_.Name -ErrorAction SilentlyContinue - if ($helpInfo) { - if ($helpInfo.Synopsis -is [array]) { - $synopsis = ($helpInfo.Synopsis | Select-Object -First 1) -join " " - } elseif ($helpInfo.Synopsis) { - $synopsis = $helpInfo.Synopsis - } - } - } catch { - Write-Verbose "Failed to get help for command '$($_.Name)': $($_.Exception.Message)" - } - - [PSCustomObject]@{ - Name = $_.Name - Type = $_.CommandType.ToString() - Source = $_.Source - Definition = $_.Definition - Synopsis = $synopsis - CommandInfo = $_ - } - }) + $allCommandObjects = @(& $DataProvider.GetItems) if (-not $allCommandObjects) { - Write-SpectreHost "[yellow]Could not retrieve command details for '$($SelectedModule.Name)'.[/]" + Write-SpectreHost "[yellow]No commands found for '$($SelectedModule.Name)'.[/]" Read-SpectrePause -Message "[grey]Press Enter to continue...[/]" -NoNewline return } @@ -166,7 +140,7 @@ function Show-ModuleCommandViewer { SearchString = '' MaxNameLength = $cfg.Layout.MinNameLength HelpOptionIndex = 0 - HelpOptions = $cfg.HelpOptions + HelpOptions = @(& $DataProvider.GetDetailOptions $null) Parameters = @() ParameterIndex = 0 ParameterListScrollOffset = 0 @@ -177,6 +151,7 @@ function Show-ModuleCommandViewer { HelpContentScrollOffset = 0 HelpContentPageSize = $dynamicPageSize LiveContext = $null + DataProvider = $DataProvider } Invoke-SpectreLive -Data $layout -ScriptBlock { diff --git a/ModuleExplorer/functions/Show-ModuleExplorer.ps1 b/ModuleExplorer/functions/Show-ModuleExplorer.ps1 index 21a8d87..ae473f9 100644 --- a/ModuleExplorer/functions/Show-ModuleExplorer.ps1 +++ b/ModuleExplorer/functions/Show-ModuleExplorer.ps1 @@ -169,7 +169,8 @@ function Show-ModuleExplorer { } Clear-Host - Show-ModuleCommandViewer -SelectedModule $selectedModuleObject + $provider = New-ModuleDataProvider -ModuleName $selectedModuleObject.Name + Show-ModuleCommandViewer -SelectedModule $selectedModuleObject -DataProvider $provider } # End of main loop } catch { diff --git a/ModuleExplorer/internal/functions/Invoke-ViewerInputHandler.ps1 b/ModuleExplorer/internal/functions/Invoke-ViewerInputHandler.ps1 index 48dc5a4..b0de79e 100644 --- a/ModuleExplorer/internal/functions/Invoke-ViewerInputHandler.ps1 +++ b/ModuleExplorer/internal/functions/Invoke-ViewerInputHandler.ps1 @@ -71,6 +71,7 @@ function Invoke-ViewerInputHandler { ([System.ConsoleKey]::RightArrow) { if ($State.CommandIndex -ge 0 -and $State.CommandIndex -lt $State.FilteredCommands.Count) { $State.CurrentCommand = $State.FilteredCommands[$State.CommandIndex] + $State.HelpOptions = @(& $State.DataProvider.GetDetailOptions $State.CurrentCommand) $State.View = 'HelpOptions' $State.HelpOptionIndex = 0 $State.SearchString = '' @@ -79,6 +80,7 @@ function Invoke-ViewerInputHandler { ([System.ConsoleKey]::Enter) { if ($State.CommandIndex -ge 0 -and $State.CommandIndex -lt $State.FilteredCommands.Count) { $State.CurrentCommand = $State.FilteredCommands[$State.CommandIndex] + $State.HelpOptions = @(& $State.DataProvider.GetDetailOptions $State.CurrentCommand) $State.View = 'HelpOptions' $State.HelpOptionIndex = 0 $State.SearchString = '' @@ -159,13 +161,13 @@ function Invoke-ViewerInputHandler { } ([System.ConsoleKey]::RightArrow) { if ($State.HelpOptions[$State.HelpOptionIndex] -eq 'Online') { - try { Get-Help $State.CurrentCommand.CommandInfo -Online } + try { & $State.DataProvider.OpenOnlineHelp $State.CurrentCommand } catch { $State.HelpContentLines = @('[red]Could not retrieve online help. Press Left to go back.[/]') } } } ([System.ConsoleKey]::Enter) { if ($State.HelpOptions[$State.HelpOptionIndex] -eq 'Online') { - try { Get-Help $State.CurrentCommand.CommandInfo -Online } + try { & $State.DataProvider.OpenOnlineHelp $State.CurrentCommand } catch { $State.HelpContentLines = @('[red]Could not retrieve online help. Press Left to go back.[/]') } } } @@ -191,14 +193,14 @@ function Invoke-ViewerHelpOptionSelect { $State.HelpContentScrollOffset = 0 if ($selectedHelpType -eq 'Parameters') { - $State.Parameters = Get-SortedParameterList -Parameters $State.CurrentCommand.CommandInfo.Parameters.Values + $State.Parameters = @(& $State.DataProvider.GetChildren $State.CurrentCommand 'Parameters') $State.ParameterIndex = 0 $State.ParameterListScrollOffset = 0 $State.View = 'ParameterList' } else { $State.View = 'HelpContent' $State.LiveContext.Refresh() - $State.HelpContentLines = Get-CommandHelpContent -HelpType $selectedHelpType -CommandInfo $State.CurrentCommand.CommandInfo + $State.HelpContentLines = @(& $State.DataProvider.GetDetailContent $State.CurrentCommand $selectedHelpType) } } @@ -213,9 +215,6 @@ function Invoke-ViewerParameterSelect { $State.HelpContentScrollOffset = 0 $State.View = 'ParameterHelpContent' $State.LiveContext.Refresh() - $State.HelpContentLines = Get-ParameterHelpContent ` - -CommandName $State.CurrentCommand.Name ` - -ParameterName $State.CurrentParameter.Name ` - -FallbackHelpMessage $State.CurrentParameter.HelpMessage + $State.HelpContentLines = @(& $State.DataProvider.GetChildContent $State.CurrentCommand $State.CurrentParameter) } } diff --git a/ModuleExplorer/internal/functions/New-ModuleDataProvider.ps1 b/ModuleExplorer/internal/functions/New-ModuleDataProvider.ps1 new file mode 100644 index 0000000..62db698 --- /dev/null +++ b/ModuleExplorer/internal/functions/New-ModuleDataProvider.ps1 @@ -0,0 +1,101 @@ +<# +.SYNOPSIS + Creates a data provider for browsing a PowerShell module's commands. + +.DESCRIPTION + Returns a hashtable implementing the data-provider contract expected by + Show-ModuleCommandViewer. Wraps Get-Command, Get-Help, Get-SortedParameterList, + Get-CommandHelpContent, and Get-ParameterHelpContent into a uniform interface + so the viewer can be driven by any compatible provider. + + Provider keys: + Title — display string for the viewer title bar + ItemTypeColors — hashtable mapping Type name to Spectre color string + GetItems — scriptblock() → PSCustomObject[] + GetDetailOptions — scriptblock($Item) → string[] + GetDetailContent — scriptblock($Item, $Option) → string[] + GetChildren — scriptblock($Item, $ChildType) → object[] + GetChildContent — scriptblock($Item, $Child) → string[] + OpenOnlineHelp — scriptblock($Item) → (launches browser, no return value) + +.PARAMETER ModuleName + The name of the PowerShell module to expose through this provider. + +.OUTPUTS + [hashtable] +#> +function New-ModuleDataProvider { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [string]$ModuleName + ) + + @{ + Title = $ModuleName + + ItemTypeColors = @{ + Cmdlet = $script:UIConfig.Colors.Cmdlet + Function = $script:UIConfig.Colors.Function + Alias = $script:UIConfig.Colors.Alias + } + + # Returns all commands in the module as PSCustomObjects with .CommandInfo + GetItems = { + $commands = Get-Command -Module $ModuleName | Sort-Object CommandType, Name + @($commands | ForEach-Object { + $synopsis = '' + try { + $helpInfo = Get-Help $_.Name -ErrorAction SilentlyContinue + if ($helpInfo.Synopsis -is [array]) { + $synopsis = ($helpInfo.Synopsis | Select-Object -First 1) -join ' ' + } elseif ($helpInfo.Synopsis) { + $synopsis = $helpInfo.Synopsis + } + } catch {} + [PSCustomObject]@{ + Name = $_.Name + Type = $_.CommandType.ToString() + Source = $_.Source + Definition = $_.Definition + Synopsis = $synopsis + CommandInfo = $_ + } + }) + }.GetNewClosure() + + # Returns the list of help option names for a selected command + GetDetailOptions = { + param($SelectedItem) + $script:UIConfig.HelpOptions + } + + # Returns formatted help-content lines for a selected help option + GetDetailContent = { + param($SelectedItem, [string]$DetailOption) + Get-CommandHelpContent -HelpType $DetailOption -CommandInfo $SelectedItem.CommandInfo + } + + # Returns the sorted parameter list for a selected command + GetChildren = { + param($SelectedItem, [string]$ChildType) + Get-SortedParameterList -Parameters $SelectedItem.CommandInfo.Parameters.Values + } + + # Returns formatted help-content lines for a selected parameter + GetChildContent = { + param($SelectedItem, $ChildItem) + Get-ParameterHelpContent ` + -CommandName $SelectedItem.Name ` + -ParameterName $ChildItem.Name ` + -FallbackHelpMessage $ChildItem.HelpMessage + } + + # Opens the online help page for a selected command in the default browser + OpenOnlineHelp = { + param($SelectedItem) + Get-Help $SelectedItem.CommandInfo -Online + } + } +} From 9fd02dff8b5800fea531bd8c5ca09b1ea6cbaf77 Mon Sep 17 00:00:00 2001 From: DearingDev Date: Fri, 17 Apr 2026 13:53:47 -0700 Subject: [PATCH 5/6] Added pester tests --- .github/workflows/build.yml | 6 +- .github/workflows/validate.yml | 15 +- CLAUDE.md | 26 +- .../functions/Get-CommandHelpContent.ps1 | 2 +- build/vsts-build.ps1 | 29 +- readme.md | 56 +++- .../Get-CommandHelpContent.Tests.ps1 | 77 +++++ .../Get-ScrollableListView.Tests.ps1 | 90 +++++ .../Get-SortedParameterList.Tests.ps1 | 80 +++++ .../Invoke-ViewerInputHandler.Tests.ps1 | 314 ++++++++++++++++++ 10 files changed, 646 insertions(+), 49 deletions(-) create mode 100644 tests/functions/Get-CommandHelpContent.Tests.ps1 create mode 100644 tests/functions/Get-ScrollableListView.Tests.ps1 create mode 100644 tests/functions/Get-SortedParameterList.Tests.ps1 create mode 100644 tests/functions/Invoke-ViewerInputHandler.Tests.ps1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1cea5ab..46c8759 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -on: +on: push: branches: - master @@ -10,7 +10,7 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Install Prerequisites run: .\build\vsts-prerequisites.ps1 shell: pwsh @@ -21,4 +21,4 @@ jobs: run: .\build\vsts-build.ps1 -ApiKey $env:APIKEY shell: pwsh env: - APIKEY: ${{ secrets.ApiKey }} \ No newline at end of file + APIKEY: ${{ secrets.ApiKey }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index dec1480..f635643 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,15 +1,18 @@ -on: [pull_request] +on: [pull_request] jobs: validate: + strategy: + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] - runs-on: windows-latest + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Install Prerequisites - run: .\build\vsts-prerequisites.ps1 + run: ./build/vsts-prerequisites.ps1 shell: pwsh - name: Validate - run: .\build\vsts-validate.ps1 - shell: pwsh \ No newline at end of file + run: ./build/vsts-validate.ps1 + shell: pwsh diff --git a/CLAUDE.md b/CLAUDE.md index c613ec7..47dbb8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,12 +12,32 @@ ### Project Structure ``` -ModuleExplorer/ # Module source (.psm1, .psd1, functions) -build/ # Build scripts -tests/ # Pester tests +ModuleExplorer/ +├── functions/ +│ ├── Show-ModuleExplorer.ps1 # Public: module picker TUI (entry point) +│ └── Show-ModuleCommandViewer.ps1 # Public: command/help TUI (orchestrator) +└── internal/ + ├── functions/ + │ ├── Get-ScrollableListView.ps1 # Reusable scrollable list renderer + │ ├── Get-CommandHelpContent.ps1 # Help fetcher (Examples/Detailed/Full/Online) + │ ├── Get-ParameterHelpContent.ps1 # Per-parameter help fetcher + │ ├── Get-SortedParameterList.ps1 # Sorts params: non-common first, then common + │ ├── Invoke-ViewerInputHandler.ps1 # State-machine input dispatcher + │ ├── Update-ViewerLayout.ps1 # Live layout renderer + │ └── New-ModuleDataProvider.ps1 # Data provider for PS module data + └── scripts/ + └── UIConfig.ps1 # $script:UIConfig — colors, strings, layout +build/ # Build/publish scripts +tests/ +├── general/ # Module-level tests (manifest, PSScriptAnalyzer, help coverage) +└── functions/ # Unit tests for internal helpers readme.md ``` +### Internal Architecture + +The command viewer is a five-state machine: `Description → HelpOptions → HelpContent / ParameterList → ParameterHelpContent`. All key-routing is in `Invoke-ViewerInputHandler`; all rendering in `Update-ViewerLayout`. Data is abstracted via a provider hashtable (`New-ModuleDataProvider`) passed through `$State.DataProvider`. + ### Install / Run ```powershell diff --git a/ModuleExplorer/internal/functions/Get-CommandHelpContent.ps1 b/ModuleExplorer/internal/functions/Get-CommandHelpContent.ps1 index c20ed25..d1b8f52 100644 --- a/ModuleExplorer/internal/functions/Get-CommandHelpContent.ps1 +++ b/ModuleExplorer/internal/functions/Get-CommandHelpContent.ps1 @@ -58,7 +58,7 @@ function Get-CommandHelpContent { $lines = ($helpText | Get-SpectreEscapedText) -split "`r?`n" - if ($lines.Count -eq 0 -or ($lines.Count -eq 1 -and [string]::IsNullOrWhiteSpace($lines[0]))) { + if (-not ($lines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) { return @('[grey](No content for this help type)[/]') } diff --git a/build/vsts-build.ps1 b/build/vsts-build.ps1 index e19fd98..8baef5d 100644 --- a/build/vsts-build.ps1 +++ b/build/vsts-build.ps1 @@ -6,17 +6,14 @@ Insert any build steps you may need to take before publishing it here. #> param ( $ApiKey, - + $WorkingDirectory, - + $Repository = 'PSGallery', - - [switch] - $LocalRepo, - + [switch] $SkipPublish, - + [switch] $AutoVersion ) @@ -81,18 +78,8 @@ if ($AutoVersion) #region Publish if ($SkipPublish) { return } -if ($LocalRepo) -{ - # Dependencies must go first - Write-Host "Creating Nuget Package for module: PSFramework" - New-PSMDModuleNugetPackage -ModulePath (Get-Module -Name PSFramework).ModuleBase -PackagePath . - Write-Host "Creating Nuget Package for module: ModuleExplorer" - New-PSMDModuleNugetPackage -ModulePath "$($publishDir.FullName)\ModuleExplorer" -PackagePath . -} -else -{ - # Publish to Gallery - Write-Host "Publishing the ModuleExplorer module to $($Repository)" - Publish-Module -Path "$($publishDir.FullName)\ModuleExplorer" -NuGetApiKey $ApiKey -Force -Repository $Repository -} + +# Publish to Gallery +Write-Host "Publishing the ModuleExplorer module to $($Repository)" +Publish-Module -Path "$($publishDir.FullName)\ModuleExplorer" -NuGetApiKey $ApiKey -Force -Repository $Repository #endregion Publish \ No newline at end of file diff --git a/readme.md b/readme.md index de89fe9..44c28de 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -# ModuleExplorer +# ModuleExplorer ModuleExplorer is a PowerShell module that provides an interactive, terminal-based user interface (TUI) to browse and explore PowerShell modules and their commands and parameters. @@ -13,25 +13,51 @@ Install-Module -Name ModuleExplorer ```powershell Show-ModuleExplorer ``` -`Show-ModuleExplorer` function opens up the TUI launcher to view installed modules. + +`Show-ModuleExplorer` opens an interactive TUI to browse all installed modules, explore their commands, and read help documentation without leaving the terminal. ## Features -* **Interactive Module Exploration**: Users can navigate through a list of all available PowerShell modules on the system. -* **Command Viewing**: Once a module is selected, users can view its commands (cmdlets, functions, and aliases). -* **Filtering**: The list of modules can be filtered by a search string. -* **Detailed Help**: For each command, users can view detailed help information, including synopsis, examples, and full help content. -* **Rich TUI**: Utilizes PwshSpectreConsole for an enhanced interactive experience in the terminal. +* **Interactive Module Exploration**: Navigate a list of all installed PowerShell modules with live filtering. +* **Command Viewer**: Select a module to browse its cmdlets, functions, and aliases with color-coded types. +* **Detailed Help**: View Examples, Detailed, Full, and Online help for any command directly in the TUI. +* **Parameter Browser**: Drill into a command's parameters and read per-parameter help. +* **Rich TUI**: Built on PwshSpectreConsole (Spectre.Console) for a polished terminal experience. + +## Navigation + +| Key | Action | +|---|---| +| `↑` / `↓` | Move selection | +| `→` / `Enter` | Drill into selected item | +| `←` / `Esc` | Go back (or exit) | +| Type characters | Filter/search the current list | +| `Backspace` | Delete last filter character | -## How it Works +## Architecture + +``` +ModuleExplorer/ +├── functions/ +│ ├── Show-ModuleExplorer.ps1 # Module picker — entry point +│ └── Show-ModuleCommandViewer.ps1 # Command/help TUI — orchestrator +└── internal/ + ├── functions/ + │ ├── Get-ScrollableListView.ps1 # Reusable scrollable list renderer + │ ├── Get-CommandHelpContent.ps1 # Help content fetcher (Examples/Detailed/Full/Online) + │ ├── Get-ParameterHelpContent.ps1 # Per-parameter help fetcher + │ ├── Get-SortedParameterList.ps1 # Parameter sorter (non-common first) + │ ├── Invoke-ViewerInputHandler.ps1 # State-machine input dispatcher + │ ├── Update-ViewerLayout.ps1 # Live layout renderer + │ └── New-ModuleDataProvider.ps1 # Data provider for PowerShell modules + └── scripts/ + └── UIConfig.ps1 # Centralized colors, strings, and layout constants +``` -The primary function `Show-ModuleExplorer` displays a list of available PowerShell modules. Upon selecting a module, it calls `Show-ModuleCommandViewer` to display the commands within that module. The interface allows for viewing the helps pages of a particular module. +The viewer is a five-state machine (`Description → HelpOptions → HelpContent / ParameterList → ParameterHelpContent`). All input routing lives in `Invoke-ViewerInputHandler`; all rendering lives in `Update-ViewerLayout`. The data layer is abstracted behind a provider hashtable (`New-ModuleDataProvider`) so the TUI engine can be reused for other data sources. ## Credits -- Friedrich Weinmann - - https://github.com/PowershellFrameworkCollective/PSModuleDevelopment -- Shaun Lawrie - - https://github.com/ShaunLawrie/PwshSpectreConsole -- Andrew Pla - - https://github.com/AndrewPla \ No newline at end of file +- Friedrich Weinmann — [PSModuleDevelopment](https://github.com/PowershellFrameworkCollective/PSModuleDevelopment) (project template) +- Shaun Lawrie — [PwshSpectreConsole](https://github.com/ShaunLawrie/PwshSpectreConsole) +- Andrew Pla — [AndrewPla](https://github.com/AndrewPla) diff --git a/tests/functions/Get-CommandHelpContent.Tests.ps1 b/tests/functions/Get-CommandHelpContent.Tests.ps1 new file mode 100644 index 0000000..f74757e --- /dev/null +++ b/tests/functions/Get-CommandHelpContent.Tests.ps1 @@ -0,0 +1,77 @@ +BeforeDiscovery { + if (-not (Get-Module ModuleExplorer)) { + Import-Module (Join-Path $PSScriptRoot '..', '..', 'ModuleExplorer', 'ModuleExplorer.psm1') -Force + } +} + +Describe 'Get-CommandHelpContent' { + InModuleScope ModuleExplorer { + + Context 'Online help type' { + It 'returns a single prompt string without calling Get-Help' { + Mock Get-Help { throw 'Should not be called for Online' } + $result = @(Get-CommandHelpContent -HelpType 'Online' -CommandInfo 'Get-Item') + $result | Should -HaveCount 1 + $result[0] | Should -Match 'online help' + } + } + + Context 'Standard help types with content' { + BeforeEach { + Mock Get-Help { 'Some help content returned from Get-Help' } + Mock Get-SpectreEscapedText { param([Parameter(ValueFromPipeline)][string]$text) process { $text } } + } + + It 'returns lines for Examples help' { + $result = Get-CommandHelpContent -HelpType 'Examples' -CommandInfo 'Get-Item' + $result | Should -Not -BeNullOrEmpty + } + + It 'returns lines for Detailed help' { + $result = Get-CommandHelpContent -HelpType 'Detailed' -CommandInfo 'Get-Item' + $result | Should -Not -BeNullOrEmpty + } + + It 'returns lines for Full help' { + $result = Get-CommandHelpContent -HelpType 'Full' -CommandInfo 'Get-Item' + $result | Should -Not -BeNullOrEmpty + } + + It 'never returns $null' { + $result = Get-CommandHelpContent -HelpType 'Examples' -CommandInfo 'Get-Item' + $result | Should -Not -BeNullOrEmpty + } + } + + Context 'Empty help content' { + BeforeEach { + Mock Get-Help { '' } + Mock Get-SpectreEscapedText { param([Parameter(ValueFromPipeline)][string]$text) process { $text } } + } + + It 'returns a single grey placeholder when Get-Help returns empty string' { + $result = @(Get-CommandHelpContent -HelpType 'Examples' -CommandInfo 'Get-Item') + $result | Should -HaveCount 1 + $result[0] | Should -Match '\[grey\]' + $result[0] | Should -Match 'No content' + } + } + + Context 'Get-Help throws an exception' { + BeforeEach { + Mock Get-Help { throw 'Help retrieval failed' } + Mock Get-SpectreEscapedText { param([Parameter(ValueFromPipeline)][string]$text) process { $text } } + } + + It 'returns a single red error line' { + $result = @(Get-CommandHelpContent -HelpType 'Examples' -CommandInfo 'FakeCommand') + $result | Should -HaveCount 1 + $result[0] | Should -Match '\[red\]' + } + + It 'never throws' { + { Get-CommandHelpContent -HelpType 'Full' -CommandInfo 'BadCmd' } | Should -Not -Throw + } + } + } +} diff --git a/tests/functions/Get-ScrollableListView.Tests.ps1 b/tests/functions/Get-ScrollableListView.Tests.ps1 new file mode 100644 index 0000000..1c89eed --- /dev/null +++ b/tests/functions/Get-ScrollableListView.Tests.ps1 @@ -0,0 +1,90 @@ +BeforeDiscovery { + if (-not (Get-Module ModuleExplorer)) { + Import-Module (Join-Path $PSScriptRoot '..', '..', 'ModuleExplorer', 'ModuleExplorer.psm1') -Force + } +} + +Describe 'Get-ScrollableListView' { + InModuleScope ModuleExplorer { + + Context 'Empty item list' { + It 'returns a single no-items placeholder line' { + $result = @(Get-ScrollableListView -Items @() -SelectedIndex 0 -ScrollOffset 0 -PageSize 10) + $result | Should -HaveCount 1 + $result[0] | Should -Match 'No items' + } + } + + Context 'All items fit within PageSize' { + It 'returns exactly as many lines as there are items' { + $items = 'Alpha', 'Beta', 'Gamma' + $result = Get-ScrollableListView -Items $items -SelectedIndex 0 -ScrollOffset 0 -PageSize 10 + $result | Should -HaveCount 3 + } + + It 'does not add any scroll indicators' { + $items = 'Alpha', 'Beta', 'Gamma' + $result = Get-ScrollableListView -Items $items -SelectedIndex 0 -ScrollOffset 0 -PageSize 10 + $result | Should -Not -Contain '[grey] ↑ ...[/]' + $result | Should -Not -Contain '[grey] ↓ ...[/]' + } + + It 'prefixes the selected item with the yellow-bold cursor' { + $items = 'Alpha', 'Beta', 'Gamma' + $result = Get-ScrollableListView -Items $items -SelectedIndex 1 -ScrollOffset 0 -PageSize 10 + $result[1] | Should -Match '^\[yellow bold\]>\[/\]' + } + + It 'does not add a cursor when SelectedIndex is -1' { + $items = 'Alpha', 'Beta' + $result = Get-ScrollableListView -Items $items -SelectedIndex -1 -ScrollOffset 0 -PageSize 10 + $result | ForEach-Object { $_ | Should -Not -Match '^\[yellow bold\]' } + } + } + + Context 'Scroll indicators' { + It 'adds a down indicator when content extends below the visible window' { + $items = 1..20 | ForEach-Object { "Item$_" } + $result = Get-ScrollableListView -Items $items -SelectedIndex 0 -ScrollOffset 0 -PageSize 5 + $result[-1] | Should -Be '[grey] ↓ ...[/]' + } + + It 'adds an up indicator when ScrollOffset is greater than 0' { + $items = 1..20 | ForEach-Object { "Item$_" } + $result = Get-ScrollableListView -Items $items -SelectedIndex 5 -ScrollOffset 5 -PageSize 5 + $result[0] | Should -Be '[grey] ↑ ...[/]' + } + + It 'adds both indicators when scrolled into the middle of the list' { + $items = 1..30 | ForEach-Object { "Item$_" } + $result = Get-ScrollableListView -Items $items -SelectedIndex 10 -ScrollOffset 5 -PageSize 5 + $result[0] | Should -Be '[grey] ↑ ...[/]' + $result[-1] | Should -Be '[grey] ↓ ...[/]' + } + } + + Context 'Custom ItemFormatter' { + It 'calls the formatter for each visible item' { + $items = 'A', 'B', 'C' + $formatter = { param($item, $index, $isSelected) "fmt:$item" } + $result = Get-ScrollableListView -Items $items -SelectedIndex 0 -ScrollOffset 0 -PageSize 10 -ItemFormatter $formatter + $result[0] | Should -Be 'fmt:A' + $result[1] | Should -Be 'fmt:B' + $result[2] | Should -Be 'fmt:C' + } + + It 'passes IsSelected=$true only for the selected item' { + $items = 'X', 'Y' + $capturedIsSelected = [System.Collections.Generic.List[object]]::new() + $formatter = { + param($item, $index, $isSelected) + $capturedIsSelected.Add($isSelected) + $item + } + Get-ScrollableListView -Items $items -SelectedIndex 1 -ScrollOffset 0 -PageSize 10 -ItemFormatter $formatter | Out-Null + $capturedIsSelected[0] | Should -Be $false + $capturedIsSelected[1] | Should -Be $true + } + } + } +} diff --git a/tests/functions/Get-SortedParameterList.Tests.ps1 b/tests/functions/Get-SortedParameterList.Tests.ps1 new file mode 100644 index 0000000..c8619d0 --- /dev/null +++ b/tests/functions/Get-SortedParameterList.Tests.ps1 @@ -0,0 +1,80 @@ +BeforeDiscovery { + if (-not (Get-Module ModuleExplorer)) { + Import-Module (Join-Path $PSScriptRoot '..', '..', 'ModuleExplorer', 'ModuleExplorer.psm1') -Force + } +} + +Describe 'Get-SortedParameterList' { + InModuleScope ModuleExplorer { + + BeforeAll { + # Get-ChildItem has both command-specific and common parameters on all platforms. + $script:allParams = (Get-Command Get-ChildItem).Parameters.Values + + $script:commonNames = @( + 'Verbose', 'Debug', 'ErrorAction', 'ErrorVariable', 'WarningAction', + 'WarningVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', + 'InformationAction', 'InformationVariable', 'ProgressAction' + ) + } + + It 'returns an array of ParameterMetadata objects' { + $result = Get-SortedParameterList -Parameters $allParams + $result | Should -Not -BeNullOrEmpty + $result[0] | Should -BeOfType [System.Management.Automation.ParameterMetadata] + } + + It 'places all non-common parameters before any common parameter' { + $result = Get-SortedParameterList -Parameters $allParams + + $firstCommonIndex = [int]::MaxValue + $lastNonCommonIndex = -1 + + for ($i = 0; $i -lt $result.Count; $i++) { + if ($result[$i].Name -in $commonNames) { + if ($i -lt $firstCommonIndex) { $firstCommonIndex = $i } + } else { + if ($i -gt $lastNonCommonIndex) { $lastNonCommonIndex = $i } + } + } + + # Only assert ordering when both groups are present + if ($firstCommonIndex -ne [int]::MaxValue -and $lastNonCommonIndex -ne -1) { + $lastNonCommonIndex | Should -BeLessThan $firstCommonIndex + } + } + + It 'sorts non-common parameters alphabetically by name' { + $result = Get-SortedParameterList -Parameters $allParams + $nonCommon = @($result | Where-Object { $_.Name -notin $commonNames }) + $sorted = @($nonCommon | Sort-Object Name) + for ($i = 0; $i -lt $nonCommon.Count; $i++) { + $nonCommon[$i].Name | Should -Be $sorted[$i].Name + } + } + + It 'sorts common parameters alphabetically by name' { + $result = Get-SortedParameterList -Parameters $allParams + $common = @($result | Where-Object { $_.Name -in $commonNames }) + $sorted = @($common | Sort-Object Name) + for ($i = 0; $i -lt $common.Count; $i++) { + $common[$i].Name | Should -Be $sorted[$i].Name + } + } + + It 'returns an empty array for null input' { + $result = Get-SortedParameterList -Parameters $null + $result.Count | Should -Be 0 + } + + It 'returns an empty array for an empty collection' { + $result = Get-SortedParameterList -Parameters @() + $result.Count | Should -Be 0 + } + + It 'preserves all parameters — none are dropped' { + $result = Get-SortedParameterList -Parameters $allParams + $result.Count | Should -Be $allParams.Count + } + } +} diff --git a/tests/functions/Invoke-ViewerInputHandler.Tests.ps1 b/tests/functions/Invoke-ViewerInputHandler.Tests.ps1 new file mode 100644 index 0000000..6716b5d --- /dev/null +++ b/tests/functions/Invoke-ViewerInputHandler.Tests.ps1 @@ -0,0 +1,314 @@ +BeforeDiscovery { + if (-not (Get-Module ModuleExplorer)) { + Import-Module (Join-Path $PSScriptRoot '..', '..', 'ModuleExplorer', 'ModuleExplorer.psm1') -Force + } +} + +Describe 'Invoke-ViewerInputHandler' { + InModuleScope ModuleExplorer { + + BeforeAll { + function New-KeyInfo { + param( + [System.ConsoleKey]$Key, + [char]$KeyChar = [char]0 + ) + [System.ConsoleKeyInfo]::new($KeyChar, $Key, $false, $false, $false) + } + + function New-TestState { + param([string]$View = 'Description') + + $mockProvider = @{ + GetDetailOptions = { @('Examples', 'Detailed', 'Full', 'Online', 'Parameters') } + GetDetailContent = { @('line one', 'line two') } + GetChildren = { @() } + GetChildContent = { @() } + OpenOnlineHelp = { } + } + + return @{ + View = $View + CommandIndex = 0 + CommandListScrollOffset = 0 + CommandListPageSize = 10 + FilteredCommands = @( + [pscustomobject]@{ Name = 'Get-Foo'; CommandType = 'Function'; Synopsis = 'First' } + [pscustomobject]@{ Name = 'Get-Bar'; CommandType = 'Cmdlet'; Synopsis = 'Second' } + ) + SearchString = '' + HelpOptionIndex = 0 + HelpOptions = @('Examples', 'Detailed', 'Full', 'Online', 'Parameters') + ParameterIndex = 0 + ParameterListScrollOffset = 0 + ParameterListPageSize = 10 + Parameters = @() + CurrentCommand = $null + CurrentParameter = $null + HelpContentLines = @() + HelpContentScrollOffset = 0 + HelpContentPageSize = 10 + DataProvider = $mockProvider + LiveContext = New-Object PSObject | Add-Member -MemberType ScriptMethod -Name Refresh -Value {} -PassThru + } + } + } + + # --- Description view: navigation --- + + Context 'Description view — Up/Down navigation' { + It 'Up arrow decrements CommandIndex by 1' { + $state = New-TestState + $state.CommandIndex = 1 + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo UpArrow) -State $state + $state.CommandIndex | Should -Be 0 + } + + It 'Up arrow does not decrement below 0' { + $state = New-TestState + $state.CommandIndex = 0 + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo UpArrow) -State $state + $state.CommandIndex | Should -Be 0 + } + + It 'Down arrow increments CommandIndex by 1' { + $state = New-TestState + $state.CommandIndex = 0 + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo DownArrow) -State $state + $state.CommandIndex | Should -Be 1 + } + + It 'Down arrow does not exceed the last item index' { + $state = New-TestState + $state.CommandIndex = 1 # last of 2 items + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo DownArrow) -State $state + $state.CommandIndex | Should -Be 1 + } + } + + # --- Description view: view transitions --- + + Context 'Description view — transitions' { + It 'Right arrow transitions to HelpOptions and returns $true' { + $state = New-TestState + $result = Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo RightArrow) -State $state + $state.View | Should -Be 'HelpOptions' + $result | Should -Be $true + } + + It 'Enter transitions to HelpOptions' { + $state = New-TestState + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo Enter) -State $state + $state.View | Should -Be 'HelpOptions' + } + + It 'Left arrow with empty SearchString returns $null (exit signal)' { + $state = New-TestState + $result = Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo LeftArrow) -State $state + $result | Should -BeNullOrEmpty + } + + It 'Left arrow with a non-empty SearchString removes the last character' { + $state = New-TestState + $state.SearchString = 'Get-F' + $result = Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo LeftArrow) -State $state + $state.SearchString | Should -Be 'Get-' + $result | Should -Be $true + } + + It 'Backspace removes the last character from SearchString' { + $state = New-TestState + $state.SearchString = 'abc' + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo Backspace) -State $state + $state.SearchString | Should -Be 'ab' + } + + It 'Backspace on empty SearchString leaves it empty' { + $state = New-TestState + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo Backspace) -State $state + $state.SearchString | Should -Be '' + } + } + + # --- Description view: type-to-search --- + + Context 'Description view — type-to-search' { + It 'typing a lowercase letter appends it to SearchString' { + $state = New-TestState + $key = [System.ConsoleKeyInfo]::new([char]'g', [System.ConsoleKey]::G, $false, $false, $false) + Invoke-ViewerInputHandler -KeyInfo $key -State $state + $state.SearchString | Should -Be 'g' + } + + It 'typing a digit appends it to SearchString' { + $state = New-TestState + $key = [System.ConsoleKeyInfo]::new([char]'3', [System.ConsoleKey]::D3, $false, $false, $false) + Invoke-ViewerInputHandler -KeyInfo $key -State $state + $state.SearchString | Should -Be '3' + } + + It 'typing resets CommandIndex and ScrollOffset to 0' { + $state = New-TestState + $state.CommandIndex = 1 + $state.CommandListScrollOffset = 2 + $key = [System.ConsoleKeyInfo]::new([char]'x', [System.ConsoleKey]::X, $false, $false, $false) + Invoke-ViewerInputHandler -KeyInfo $key -State $state + $state.CommandIndex | Should -Be 0 + $state.CommandListScrollOffset | Should -Be 0 + } + } + + # --- HelpOptions view --- + + Context 'HelpOptions view' { + It 'Up arrow decrements HelpOptionIndex' { + $state = New-TestState -View 'HelpOptions' + $state.HelpOptionIndex = 2 + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo UpArrow) -State $state + $state.HelpOptionIndex | Should -Be 1 + } + + It 'Down arrow increments HelpOptionIndex' { + $state = New-TestState -View 'HelpOptions' + $state.HelpOptionIndex = 0 + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo DownArrow) -State $state + $state.HelpOptionIndex | Should -Be 1 + } + + It 'Left arrow transitions back to Description' { + $state = New-TestState -View 'HelpOptions' + $state.CurrentCommand = $state.FilteredCommands[0] + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo LeftArrow) -State $state + $state.View | Should -Be 'Description' + } + + It 'Right arrow with "Parameters" selected transitions to ParameterList' { + $state = New-TestState -View 'HelpOptions' + $state.CurrentCommand = $state.FilteredCommands[0] + $state.HelpOptionIndex = 4 # index of 'Parameters' + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo RightArrow) -State $state + $state.View | Should -Be 'ParameterList' + } + + It 'Right arrow with a non-Parameters option transitions to HelpContent' { + $state = New-TestState -View 'HelpOptions' + $state.CurrentCommand = $state.FilteredCommands[0] + $state.HelpOptionIndex = 0 # 'Examples' + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo RightArrow) -State $state + $state.View | Should -Be 'HelpContent' + } + + It 'Enter behaves the same as Right arrow for option selection' { + $state = New-TestState -View 'HelpOptions' + $state.CurrentCommand = $state.FilteredCommands[0] + $state.HelpOptionIndex = 0 + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo Enter) -State $state + $state.View | Should -Be 'HelpContent' + } + } + + # --- HelpContent view --- + + Context 'HelpContent view' { + It 'Left arrow returns to HelpOptions' { + $state = New-TestState -View 'HelpContent' + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo LeftArrow) -State $state + $state.View | Should -Be 'HelpOptions' + } + + It 'Left arrow clears HelpContentLines and resets scroll' { + $state = New-TestState -View 'HelpContent' + $state.HelpContentLines = @('line1', 'line2') + $state.HelpContentScrollOffset = 1 + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo LeftArrow) -State $state + $state.HelpContentLines.Count | Should -Be 0 + $state.HelpContentScrollOffset | Should -Be 0 + } + + It 'Up arrow decrements HelpContentScrollOffset' { + $state = New-TestState -View 'HelpContent' + $state.HelpContentLines = 1..20 | ForEach-Object { "line$_" } + $state.HelpContentScrollOffset = 3 + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo UpArrow) -State $state + $state.HelpContentScrollOffset | Should -Be 2 + } + + It 'Up arrow does not decrement scroll below 0' { + $state = New-TestState -View 'HelpContent' + $state.HelpContentLines = @('only line') + $state.HelpContentScrollOffset = 0 + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo UpArrow) -State $state + $state.HelpContentScrollOffset | Should -Be 0 + } + + It 'Down arrow increments scroll when content extends below' { + $state = New-TestState -View 'HelpContent' + $state.HelpContentLines = 1..20 | ForEach-Object { "line$_" } + $state.HelpContentScrollOffset = 0 + $state.HelpContentPageSize = 5 + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo DownArrow) -State $state + $state.HelpContentScrollOffset | Should -Be 1 + } + + It 'Down arrow does not scroll past the last page' { + $state = New-TestState -View 'HelpContent' + $state.HelpContentLines = 1..5 | ForEach-Object { "line$_" } + $state.HelpContentScrollOffset = 0 + $state.HelpContentPageSize = 10 # all lines fit + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo DownArrow) -State $state + $state.HelpContentScrollOffset | Should -Be 0 + } + } + + # --- ParameterList view --- + + Context 'ParameterList view' { + It 'Left arrow returns to HelpOptions' { + $state = New-TestState -View 'ParameterList' + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo LeftArrow) -State $state + $state.View | Should -Be 'HelpOptions' + } + + It 'Left arrow clears Parameters and CurrentParameter' { + $state = New-TestState -View 'ParameterList' + $state.Parameters = @([pscustomobject]@{ Name = 'Path' }) + $state.CurrentParameter = $state.Parameters[0] + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo LeftArrow) -State $state + $state.Parameters.Count | Should -Be 0 + $state.CurrentParameter | Should -BeNullOrEmpty + } + } + + # --- ParameterHelpContent view --- + + Context 'ParameterHelpContent view' { + It 'Left arrow returns to ParameterList' { + $state = New-TestState -View 'ParameterHelpContent' + Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo LeftArrow) -State $state + $state.View | Should -Be 'ParameterList' + } + } + + # --- Return values --- + + Context 'Return values' { + It 'returns $true for normal navigation in Description view' { + $state = New-TestState + $result = Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo DownArrow) -State $state + $result | Should -Be $true + } + + It 'returns $null when Left arrow with empty search is pressed in Description view' { + $state = New-TestState + $result = Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo LeftArrow) -State $state + $result | Should -BeNullOrEmpty + } + + It 'returns $true for navigation in HelpOptions view' { + $state = New-TestState -View 'HelpOptions' + $result = Invoke-ViewerInputHandler -KeyInfo (New-KeyInfo UpArrow) -State $state + $result | Should -Be $true + } + } + } +} From 67c2af1d99a8d8e2880ee921d9586db76dfc0a56 Mon Sep 17 00:00:00 2001 From: DearingDev Date: Sat, 18 Apr 2026 16:18:31 -0500 Subject: [PATCH 6/6] Fix encoding, missing help, and build logic --- ModuleExplorer/ModuleExplorer.psd1 | 162 ++++++++++-------- .../functions/Show-ModuleCommandViewer.ps1 | 8 +- .../functions/Show-ModuleExplorer.ps1 | 2 +- .../functions/Get-CommandHelpContent.ps1 | 7 +- .../functions/Get-ParameterHelpContent.ps1 | 6 +- .../functions/Get-ScrollableListView.ps1 | 6 +- .../functions/Get-SortedParameterList.ps1 | 6 +- .../functions/Invoke-ViewerInputHandler.ps1 | 10 +- .../functions/New-ModuleDataProvider.ps1 | 10 +- .../functions/Update-ViewerLayout.ps1 | 10 +- ModuleExplorer/internal/scripts/UIConfig.ps1 | 3 +- build/vsts-build.ps1 | 54 ++++-- .../Get-CommandHelpContent.Tests.ps1 | 3 +- .../Get-ScrollableListView.Tests.ps1 | 3 +- .../Get-SortedParameterList.Tests.ps1 | 3 +- .../Invoke-ViewerInputHandler.Tests.ps1 | 3 +- tests/general/FileIntegrity.Exceptions.ps1 | 2 +- tests/general/FileIntegrity.Tests.ps1 | 11 +- tests/general/Help.Exceptions.ps1 | 1 + tests/general/Help.Tests.ps1 | 2 +- tests/general/Manifest.Tests.ps1 | 2 +- tests/general/PSScriptAnalyzer.Tests.ps1 | 2 +- tests/pester.ps1 | 2 +- 23 files changed, 202 insertions(+), 116 deletions(-) diff --git a/ModuleExplorer/ModuleExplorer.psd1 b/ModuleExplorer/ModuleExplorer.psd1 index 9757d93..3352a20 100644 --- a/ModuleExplorer/ModuleExplorer.psd1 +++ b/ModuleExplorer/ModuleExplorer.psd1 @@ -1,122 +1,132 @@ -# +# # Module manifest for module 'ModuleExplorer' # # Generated by: Josh Dearing # -# Generated on: 5/17/2025 +# Generated on: 4/18/2026 # @{ - # Script module or binary module file associated with this manifest. - RootModule = 'ModuleExplorer.psm1' +# Script module or binary module file associated with this manifest. +RootModule = 'ModuleExplorer.psm1' + +# Version number of this module. +ModuleVersion = '0.2.0' + +# Supported PSEditions +CompatiblePSEditions = 'Core' + +# ID used to uniquely identify this module +GUID = '244270ce-191e-4f04-adff-e8677a893280' + +# Author of this module +Author = 'Josh Dearing' + +# Company or vendor of this module +CompanyName = ' ' - # Version number of this module. - ModuleVersion = '0.1.10' +# Copyright statement for this module +Copyright = '(c) Joshua Dearing. All rights reserved.' - # Supported PSEditions - CompatiblePSEditions = 'Core' +# Description of the functionality provided by this module +Description = 'A TUI for interactively exploring installed PowerShell modules and their commands.' - # ID used to uniquely identify this module - GUID = '244270ce-191e-4f04-adff-e8677a893280' +# Minimum version of the PowerShell engine required by this module +PowerShellVersion = '7.4' - # Author of this module - Author = 'Josh Dearing' +# Name of the PowerShell host required by this module +# PowerShellHostName = '' - # Company or vendor of this module - CompanyName = ' ' +# Minimum version of the PowerShell host required by this module +# PowerShellHostVersion = '' - # Copyright statement for this module - Copyright = '(c) Joshua Dearing. All rights reserved.' +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# DotNetFrameworkVersion = '' - # Description of the functionality provided by this module - Description = 'A TUI for interactively exploring installed PowerShell modules and their commands.' +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# ClrVersion = '' - # Minimum version of the PowerShell engine required by this module - PowerShellVersion = '7.4' +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' - # Modules that must be imported into the global environment prior to importing this module - RequiredModules = @( - @{ ModuleName = 'PwshSpectreConsole'; ModuleVersion = '2.3.0' } - ) +# Modules that must be imported into the global environment prior to importing this module +RequiredModules = @(@{ModuleName = 'PwshSpectreConsole'; ModuleVersion = '2.3.0'; }) - # Assemblies that must be loaded prior to importing this module - # RequiredAssemblies = @() +# Assemblies that must be loaded prior to importing this module +# RequiredAssemblies = @() - # Script files (.ps1) that are run in the caller's environment prior to importing this module. - # ScriptsToProcess = @() +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +# ScriptsToProcess = @() - # Type files (.ps1xml) to be loaded when importing this module - # TypesToProcess = @() +# Type files (.ps1xml) to be loaded when importing this module +# TypesToProcess = @() - # Format files (.ps1xml) to be loaded when importing this module - # FormatsToProcess = @() +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() - # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess - # NestedModules = @() +# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess +# NestedModules = @() - # Functions 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 functions to export. - FunctionsToExport = @( - 'Show-ModuleExplorer' - 'Show-ModuleCommandViewer' - ) +# Functions 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 functions to export. +FunctionsToExport = 'Show-ModuleExplorer', 'Show-ModuleCommandViewer' - # 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 = @() +# 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 = @() - # Variables to export from this module - VariablesToExport = '*' +# 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 = @() +# 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 = @() - # DSC resources to export from this module - # DscResourcesToExport = @() +# DSC resources to export from this module +# DscResourcesToExport = @() - # List of all modules packaged with this module - # ModuleList = @() +# List of all modules packaged with this module +# ModuleList = @() - # List of all files packaged with this module - # FileList = @() +# List of all files packaged with this module +# FileList = @() - # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. - PrivateData = @{ +# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. +PrivateData = @{ - PSData = @{ + PSData = @{ - # Tags applied to this module. These help with module discovery in online galleries. - # Tags = @() + # Tags applied to this module. These help with module discovery in online galleries. + # Tags = @() - # A URL to the license for this module. - LicenseUri = 'https://github.com/dearingdev/ModuleExplorer/blob/main/LICENSE' + # A URL to the license for this module. + LicenseUri = 'https://github.com/dearingdev/ModuleExplorer/blob/main/LICENSE' - # A URL to the main website for this project. - ProjectUri = 'https://github.com/dearingdev/ModuleExplorer' + # A URL to the main website for this project. + ProjectUri = 'https://github.com/dearingdev/ModuleExplorer' - # A URL to an icon representing this module. - # IconUri = '' + # A URL to an icon representing this module. + # IconUri = '' - # ReleaseNotes of this module - # ReleaseNotes = '' + # ReleaseNotes of this module + # ReleaseNotes = '' - # Prerelease string of this module - # Prerelease = '' + # Prerelease string of this module + # Prerelease = '' - # Flag to indicate whether the module requires explicit user acceptance for install/update/save - # RequireLicenseAcceptance = $false + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false - # External dependent modules of this module - # ExternalModuleDependencies = @() + # External dependent modules of this module + # ExternalModuleDependencies = @() - } # End of PSData hashtable + } # End of PSData hashtable - } # End of PrivateData hashtable + } # End of PrivateData hashtable - # HelpInfo URI of this module - # HelpInfoURI = '' +# HelpInfo URI of this module +# HelpInfoURI = '' - # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. - # DefaultCommandPrefix = '' +# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. +# DefaultCommandPrefix = '' } diff --git a/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 b/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 index f54c79f..dd19225 100644 --- a/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 +++ b/ModuleExplorer/functions/Show-ModuleCommandViewer.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Displays an interactive viewer for commands (cmdlets, functions, aliases) within a selected PowerShell module. @@ -30,6 +30,11 @@ (e.g., as returned by Get-Module). This parameter is mandatory. It is provided by the caller of this function, the Show-ModuleExplorer function. +.PARAMETER DataProvider + An optional hashtable providing the data and logic for the viewer. If not supplied, + a default provider for the selected module is created using New-ModuleDataProvider. + This allows for dependency injection or alternative data sources. + .INPUTS System.Management.Automation.PSObject Expects a PSObject with a .Name property that is the name of a module. @@ -192,3 +197,4 @@ function Show-ModuleCommandViewer { Clear-Host } + diff --git a/ModuleExplorer/functions/Show-ModuleExplorer.ps1 b/ModuleExplorer/functions/Show-ModuleExplorer.ps1 index ae473f9..de8d739 100644 --- a/ModuleExplorer/functions/Show-ModuleExplorer.ps1 +++ b/ModuleExplorer/functions/Show-ModuleExplorer.ps1 @@ -182,4 +182,4 @@ function Show-ModuleExplorer { Clear-Host Write-SpectreHost "[cyan]Module Explorer session ended.[/]" } -} \ No newline at end of file +} diff --git a/ModuleExplorer/internal/functions/Get-CommandHelpContent.ps1 b/ModuleExplorer/internal/functions/Get-CommandHelpContent.ps1 index d1b8f52..3cce28c 100644 --- a/ModuleExplorer/internal/functions/Get-CommandHelpContent.ps1 +++ b/ModuleExplorer/internal/functions/Get-CommandHelpContent.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Fetches and formats help content for a PowerShell command into Spectre-safe string lines. @@ -30,7 +30,9 @@ .EXAMPLE $lines = Get-CommandHelpContent -HelpType 'Examples' -CommandInfo $cmd.CommandInfo - # Store in $currentHelpContentLines for the viewer to render + + Retrieves the Examples help section for the specified command and returns it as + an array of Spectre-escaped strings. #> function Get-CommandHelpContent { [CmdletBinding()] @@ -69,3 +71,4 @@ function Get-CommandHelpContent { return @("[red]Could not retrieve help: $escapedMsg[/]") } } + diff --git a/ModuleExplorer/internal/functions/Get-ParameterHelpContent.ps1 b/ModuleExplorer/internal/functions/Get-ParameterHelpContent.ps1 index 311186b..6a407e4 100644 --- a/ModuleExplorer/internal/functions/Get-ParameterHelpContent.ps1 +++ b/ModuleExplorer/internal/functions/Get-ParameterHelpContent.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Fetches and formats parameter-specific help content into Spectre-safe string lines. @@ -32,6 +32,9 @@ -CommandName $state.CurrentCommand.Name ` -ParameterName $selectedParam.Name ` -FallbackHelpMessage $selectedParam.HelpMessage + + Retrieves and formats the help content for a specific parameter, using a fallback + message if the help content is empty. #> function Get-ParameterHelpContent { [CmdletBinding()] @@ -66,3 +69,4 @@ function Get-ParameterHelpContent { return @("[red]Could not retrieve help for parameter '$escapedName': $escapedMsg[/]") } } + diff --git a/ModuleExplorer/internal/functions/Get-ScrollableListView.ps1 b/ModuleExplorer/internal/functions/Get-ScrollableListView.ps1 index d754f2d..c671584 100644 --- a/ModuleExplorer/internal/functions/Get-ScrollableListView.ps1 +++ b/ModuleExplorer/internal/functions/Get-ScrollableListView.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Returns a window into a list of items suitable for rendering in a scrollable TUI pane. @@ -41,6 +41,9 @@ .EXAMPLE $lines = Get-ScrollableListView -Items $myItems -SelectedIndex 3 -ScrollOffset 1 -PageSize 10 $lines | Format-SpectreRows | Format-SpectrePanel -Expand -Border Rounded + + Returns a sliced window of items with scroll indicators if necessary, ready to be + displayed in a Spectre Console panel. #> function Get-ScrollableListView { [CmdletBinding()] @@ -109,3 +112,4 @@ function Get-ScrollableListView { return $result.ToArray() } + diff --git a/ModuleExplorer/internal/functions/Get-SortedParameterList.ps1 b/ModuleExplorer/internal/functions/Get-SortedParameterList.ps1 index e36b8ee..1587a41 100644 --- a/ModuleExplorer/internal/functions/Get-SortedParameterList.ps1 +++ b/ModuleExplorer/internal/functions/Get-SortedParameterList.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Returns a command's parameters sorted with non-common parameters first. @@ -27,7 +27,8 @@ .EXAMPLE $sorted = Get-SortedParameterList -Parameters $commandInfo.Parameters.Values - # Use $sorted as the $commandParametersForHelp list in the viewer + + Sorts the command parameters such that common parameters are at the bottom of the list. #> function Get-SortedParameterList { [CmdletBinding()] @@ -59,3 +60,4 @@ function Get-SortedParameterList { return $ordered.ToArray() } + diff --git a/ModuleExplorer/internal/functions/Invoke-ViewerInputHandler.ps1 b/ModuleExplorer/internal/functions/Invoke-ViewerInputHandler.ps1 index b0de79e..cf8cafb 100644 --- a/ModuleExplorer/internal/functions/Invoke-ViewerInputHandler.ps1 +++ b/ModuleExplorer/internal/functions/Invoke-ViewerInputHandler.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Routes a console key press to the correct handler for the viewer's current state. @@ -23,6 +23,13 @@ .OUTPUTS $null — caller should exit the viewer loop. $true — caller should continue the viewer loop. + +.EXAMPLE + $keyInfo = [Console]::ReadKey($true) + $result = Invoke-ViewerInputHandler -KeyInfo $keyInfo -State $state + + Processes the key press based on the current view in the state hashtable and updates + the state accordingly. #> function Invoke-ViewerInputHandler { [CmdletBinding()] @@ -218,3 +225,4 @@ function Invoke-ViewerParameterSelect { $State.HelpContentLines = @(& $State.DataProvider.GetChildContent $State.CurrentCommand $State.CurrentParameter) } } + diff --git a/ModuleExplorer/internal/functions/New-ModuleDataProvider.ps1 b/ModuleExplorer/internal/functions/New-ModuleDataProvider.ps1 index 62db698..dbe3c4b 100644 --- a/ModuleExplorer/internal/functions/New-ModuleDataProvider.ps1 +++ b/ModuleExplorer/internal/functions/New-ModuleDataProvider.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Creates a data provider for browsing a PowerShell module's commands. @@ -23,6 +23,13 @@ .OUTPUTS [hashtable] + +.EXAMPLE + $provider = New-ModuleDataProvider -ModuleName 'Pester' + $items = & $provider.GetItems + + Creates a provider for the Pester module and retrieves its commands as a collection + of PSCustomObjects. #> function New-ModuleDataProvider { [CmdletBinding()] @@ -99,3 +106,4 @@ function New-ModuleDataProvider { } } } + diff --git a/ModuleExplorer/internal/functions/Update-ViewerLayout.ps1 b/ModuleExplorer/internal/functions/Update-ViewerLayout.ps1 index 691fad9..b1ca41c 100644 --- a/ModuleExplorer/internal/functions/Update-ViewerLayout.ps1 +++ b/ModuleExplorer/internal/functions/Update-ViewerLayout.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Renders the current viewer state into the live Spectre Console layout. @@ -21,6 +21,13 @@ .PARAMETER Layout The root Spectre Console layout object. Must have named children 'commandListPane' and 'rightPane'. + +.EXAMPLE + Update-ViewerLayout -State $state -Layout $layout + $state.LiveContext.Refresh() + + Calculates the new layout based on the current state (filtering, selection, scroll) + and updates the Spectre Console layout object. #> function Update-ViewerLayout { [CmdletBinding()] @@ -171,3 +178,4 @@ function Update-ViewerLayout { $Layout['rightPane'].Update($rightRenderable) | Out-Null } + diff --git a/ModuleExplorer/internal/scripts/UIConfig.ps1 b/ModuleExplorer/internal/scripts/UIConfig.ps1 index 672bf7e..b9fbf94 100644 --- a/ModuleExplorer/internal/scripts/UIConfig.ps1 +++ b/ModuleExplorer/internal/scripts/UIConfig.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Centralizes all hardcoded UI configuration values for the ModuleExplorer module. @@ -64,3 +64,4 @@ $script:UIConfig = @{ 'InformationAction', 'InformationVariable', 'ProgressAction' ) } + diff --git a/build/vsts-build.ps1 b/build/vsts-build.ps1 index 8baef5d..65d7a6d 100644 --- a/build/vsts-build.ps1 +++ b/build/vsts-build.ps1 @@ -31,30 +31,56 @@ if (-not $WorkingDirectory) { $WorkingDirectory = Split-Path $PSScriptRoot } #endregion Handle Working Directory Defaults # Prepare publish folder -Write-Host "Creating and populating publishing directory" +Write-Host "Creating and populating publishing directory: $($publishDir.FullName)" $publishDir = New-Item -Path $WorkingDirectory -Name publish -ItemType Directory -Force -Copy-Item -Path "$($WorkingDirectory)\ModuleExplorer" -Destination $publishDir.FullName -Recurse -Force +Copy-Item -Path (Join-Path $WorkingDirectory "ModuleExplorer") -Destination $publishDir.FullName -Recurse -Force #region Gather text data to compile $text = @() +# Preserve #Requires from the original psm1 if it exists +$originalPsm1 = Join-Path $WorkingDirectory "ModuleExplorer/ModuleExplorer.psm1" +if (Test-Path $originalPsm1) { + $psm1Content = Get-Content $originalPsm1 + $requires = $psm1Content | Where-Object { $_ -match '^#Requires' } + if ($requires) { + Write-Host "Preserving #Requires statement" + $text += $requires + $text += "" + } +} + # Gather commands -Get-ChildItem -Path "$($publishDir.FullName)\ModuleExplorer\internal\functions\" -Recurse -File -Filter "*.ps1" | ForEach-Object { - $text += [System.IO.File]::ReadAllText($_.FullName) +Write-Host "Gathering functions and scripts..." +$internalFunctionsPath = Join-Path $publishDir.FullName "ModuleExplorer/internal/functions/" +if (Test-Path $internalFunctionsPath) { + Get-ChildItem -Path $internalFunctionsPath -Recurse -File -Filter "*.ps1" | ForEach-Object { + $text += [System.IO.File]::ReadAllText($_.FullName) + } } -Get-ChildItem -Path "$($publishDir.FullName)\ModuleExplorer\functions\" -Recurse -File -Filter "*.ps1" | ForEach-Object { - $text += [System.IO.File]::ReadAllText($_.FullName) + +$functionsPath = Join-Path $publishDir.FullName "ModuleExplorer/functions/" +if (Test-Path $functionsPath) { + Get-ChildItem -Path $functionsPath -Recurse -File -Filter "*.ps1" | ForEach-Object { + $text += [System.IO.File]::ReadAllText($_.FullName) + } } # Gather scripts -Get-ChildItem -Path "$($publishDir.FullName)\ModuleExplorer\internal\scripts\" -Recurse -File -Filter "*.ps1" | ForEach-Object { - $text += [System.IO.File]::ReadAllText($_.FullName) +$internalScriptsPath = Join-Path $publishDir.FullName "ModuleExplorer/internal/scripts/" +if (Test-Path $internalScriptsPath) { + Get-ChildItem -Path $internalScriptsPath -Recurse -File -Filter "*.ps1" | ForEach-Object { + $text += [System.IO.File]::ReadAllText($_.FullName) + } } #region Update the psm1 file & Cleanup -[System.IO.File]::WriteAllText("$($publishDir.FullName)\ModuleExplorer\ModuleExplorer.psm1", ($text -join "`n`n"), [System.Text.Encoding]::UTF8) -Remove-Item -Path "$($publishDir.FullName)\ModuleExplorer\internal" -Recurse -Force -Remove-Item -Path "$($publishDir.FullName)\ModuleExplorer\functions" -Recurse -Force +Write-Host "Updating ModuleExplorer.psm1 and cleaning up subdirectories" +$targetPsm1 = Join-Path $publishDir.FullName "ModuleExplorer/ModuleExplorer.psm1" +[System.IO.File]::WriteAllText($targetPsm1, ($text -join "`n`n"), [System.Text.Encoding]::UTF8) + +Remove-Item -Path (Join-Path $publishDir.FullName "ModuleExplorer/internal") -Recurse -Force +Remove-Item -Path (Join-Path $publishDir.FullName "ModuleExplorer/functions") -Recurse -Force #endregion Update the psm1 file & Cleanup #region Updating the Module Version @@ -71,8 +97,8 @@ if ($AutoVersion) throw "Couldn't find ModuleExplorer on repository $($Repository) : $_" } $newBuildNumber = $remoteVersion.Build + 1 - [version]$localVersion = (Import-PowerShellDataFile -Path "$($publishDir.FullName)\ModuleExplorer\ModuleExplorer.psd1").ModuleVersion - Update-ModuleManifest -Path "$($publishDir.FullName)\ModuleExplorer\ModuleExplorer.psd1" -ModuleVersion "$($localVersion.Major).$($localVersion.Minor).$($newBuildNumber)" + [version]$localVersion = (Import-PowerShellDataFile -Path (Join-Path $publishDir.FullName "ModuleExplorer/ModuleExplorer.psd1")).ModuleVersion + Update-ModuleManifest -Path (Join-Path $publishDir.FullName "ModuleExplorer/ModuleExplorer.psd1") -ModuleVersion "$($localVersion.Major).$($localVersion.Minor).$($newBuildNumber)" } #endregion Updating the Module Version @@ -81,5 +107,5 @@ if ($SkipPublish) { return } # Publish to Gallery Write-Host "Publishing the ModuleExplorer module to $($Repository)" -Publish-Module -Path "$($publishDir.FullName)\ModuleExplorer" -NuGetApiKey $ApiKey -Force -Repository $Repository +Publish-Module -Path (Join-Path $publishDir.FullName "ModuleExplorer") -NuGetApiKey $ApiKey -Force -Repository $Repository #endregion Publish \ No newline at end of file diff --git a/tests/functions/Get-CommandHelpContent.Tests.ps1 b/tests/functions/Get-CommandHelpContent.Tests.ps1 index f74757e..34bbb23 100644 --- a/tests/functions/Get-CommandHelpContent.Tests.ps1 +++ b/tests/functions/Get-CommandHelpContent.Tests.ps1 @@ -1,4 +1,4 @@ -BeforeDiscovery { +BeforeDiscovery { if (-not (Get-Module ModuleExplorer)) { Import-Module (Join-Path $PSScriptRoot '..', '..', 'ModuleExplorer', 'ModuleExplorer.psm1') -Force } @@ -75,3 +75,4 @@ Describe 'Get-CommandHelpContent' { } } } + diff --git a/tests/functions/Get-ScrollableListView.Tests.ps1 b/tests/functions/Get-ScrollableListView.Tests.ps1 index 1c89eed..6915708 100644 --- a/tests/functions/Get-ScrollableListView.Tests.ps1 +++ b/tests/functions/Get-ScrollableListView.Tests.ps1 @@ -1,4 +1,4 @@ -BeforeDiscovery { +BeforeDiscovery { if (-not (Get-Module ModuleExplorer)) { Import-Module (Join-Path $PSScriptRoot '..', '..', 'ModuleExplorer', 'ModuleExplorer.psm1') -Force } @@ -88,3 +88,4 @@ Describe 'Get-ScrollableListView' { } } } + diff --git a/tests/functions/Get-SortedParameterList.Tests.ps1 b/tests/functions/Get-SortedParameterList.Tests.ps1 index c8619d0..ebc9575 100644 --- a/tests/functions/Get-SortedParameterList.Tests.ps1 +++ b/tests/functions/Get-SortedParameterList.Tests.ps1 @@ -1,4 +1,4 @@ -BeforeDiscovery { +BeforeDiscovery { if (-not (Get-Module ModuleExplorer)) { Import-Module (Join-Path $PSScriptRoot '..', '..', 'ModuleExplorer', 'ModuleExplorer.psm1') -Force } @@ -78,3 +78,4 @@ Describe 'Get-SortedParameterList' { } } } + diff --git a/tests/functions/Invoke-ViewerInputHandler.Tests.ps1 b/tests/functions/Invoke-ViewerInputHandler.Tests.ps1 index 6716b5d..1fffd62 100644 --- a/tests/functions/Invoke-ViewerInputHandler.Tests.ps1 +++ b/tests/functions/Invoke-ViewerInputHandler.Tests.ps1 @@ -1,4 +1,4 @@ -BeforeDiscovery { +BeforeDiscovery { if (-not (Get-Module ModuleExplorer)) { Import-Module (Join-Path $PSScriptRoot '..', '..', 'ModuleExplorer', 'ModuleExplorer.psm1') -Force } @@ -312,3 +312,4 @@ Describe 'Invoke-ViewerInputHandler' { } } } + diff --git a/tests/general/FileIntegrity.Exceptions.ps1 b/tests/general/FileIntegrity.Exceptions.ps1 index deb40ab..bcaae01 100644 --- a/tests/general/FileIntegrity.Exceptions.ps1 +++ b/tests/general/FileIntegrity.Exceptions.ps1 @@ -43,4 +43,4 @@ $global:MayContainCommand = @{ "Write-Information" = @() "Write-Debug" = @() "Clear-Host" = @('Show-ModuleExplorer.ps1', 'Show-ModuleCommandViewer.ps1') -} \ No newline at end of file +} diff --git a/tests/general/FileIntegrity.Tests.ps1 b/tests/general/FileIntegrity.Tests.ps1 index 8656e65..9d2db39 100644 --- a/tests/general/FileIntegrity.Tests.ps1 +++ b/tests/general/FileIntegrity.Tests.ps1 @@ -42,11 +42,12 @@ Describe "Verifying integrity of module files" { } Context "Validating PS1 Script files" { - $allFiles = Get-ChildItem -Path $moduleRoot -Recurse | Where-Object Name -like "*.ps1" | Where-Object FullName -NotLike "$moduleRoot\tests\*" + $testsPath = Join-Path $moduleRoot "tests" + $allFiles = Get-ChildItem -Path $moduleRoot -Recurse | Where-Object Name -like "*.ps1" | Where-Object { $_.FullName -notlike "$testsPath*" } foreach ($file in $allFiles) { - $name = $file.FullName.Replace("$moduleRoot\", '') + $name = $file.FullName.Replace("$moduleRoot$([IO.Path]::DirectorySeparatorChar)", "") It "[$name] Should have UTF8 encoding with Byte Order Mark" -TestCases @{ file = $file } { Get-FileEncoding -Path $file.FullName | Should -Be 'UTF8 BOM' @@ -77,11 +78,11 @@ Describe "Verifying integrity of module files" { } Context "Validating help.txt help files" { - $allFiles = Get-ChildItem -Path $moduleRoot -Recurse | Where-Object Name -like "*.help.txt" | Where-Object FullName -NotLike "$moduleRoot\tests\*" + $allFiles = Get-ChildItem -Path $moduleRoot -Recurse | Where-Object Name -like "*.help.txt" | Where-Object FullName -NotLike (Join-Path $moduleRoot "tests" -ChildPath "*") foreach ($file in $allFiles) { - $name = $file.FullName.Replace("$moduleRoot\", '') + $name = $file.FullName.Replace("$moduleRoot$([IO.Path]::DirectorySeparatorChar)", "") It "[$name] Should have UTF8 encoding" -TestCases @{ file = $file } { Get-FileEncoding -Path $file.FullName | Should -Be 'UTF8 BOM' @@ -92,4 +93,4 @@ Describe "Verifying integrity of module files" { } } } -} \ No newline at end of file +} diff --git a/tests/general/Help.Exceptions.ps1 b/tests/general/Help.Exceptions.ps1 index f9c9bd7..9cdfdf3 100644 --- a/tests/general/Help.Exceptions.ps1 +++ b/tests/general/Help.Exceptions.ps1 @@ -24,3 +24,4 @@ $global:HelpTestEnumeratedArrays = @( $global:HelpTestSkipParameterType = @{ } + diff --git a/tests/general/Help.Tests.ps1 b/tests/general/Help.Tests.ps1 index b7fac78..69d9d2d 100644 --- a/tests/general/Help.Tests.ps1 +++ b/tests/general/Help.Tests.ps1 @@ -149,4 +149,4 @@ foreach ($command in $commands) { } } } -} \ No newline at end of file +} diff --git a/tests/general/Manifest.Tests.ps1 b/tests/general/Manifest.Tests.ps1 index a99e4d2..a2e13eb 100644 --- a/tests/general/Manifest.Tests.ps1 +++ b/tests/general/Manifest.Tests.ps1 @@ -59,4 +59,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/general/PSScriptAnalyzer.Tests.ps1 b/tests/general/PSScriptAnalyzer.Tests.ps1 index 5aa9c73..25c0c39 100644 --- a/tests/general/PSScriptAnalyzer.Tests.ps1 +++ b/tests/general/PSScriptAnalyzer.Tests.ps1 @@ -47,4 +47,4 @@ Describe 'Invoking PSScriptAnalyzer against commandbase' { } | Should -BeNullOrEmpty } } -} \ No newline at end of file +} diff --git a/tests/pester.ps1 b/tests/pester.ps1 index f120ccc..834c6f9 100644 --- a/tests/pester.ps1 +++ b/tests/pester.ps1 @@ -110,4 +110,4 @@ else { Write-Host "$totalFailed tests out of $totalRun tests failed!" } if ($totalFailed -gt 0) { throw "$totalFailed / $totalRun tests failed!" -} \ No newline at end of file +}