CommandNavigator.psm1
|
# Command Navigator - keyboard-first PowerShell command tree # Load once per session: . "$HOME\CommandNavigator.ps1" # Then run: cmdnav function cmdnav { [CmdletBinding()] param() $settingsFile = 'settings.json' $commandsFile = 'commands.json' $projectCommandsFile = 'nav_commands.json' $configDir = Join-Path $env:LOCALAPPDATA 'CommandNavigator' # Ensure the configuration directory exists if (-not (Test-Path $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } # Ensure the configuration files exist in the configuration directory foreach ($file in @($commandsFile, $settingsFile)) { $destination = Join-Path $configDir $file if (-not (Test-Path $destination)) { Copy-Item (Join-Path $PSScriptRoot $file) $destination } } # Load commands.json from the script's directory $configPath = Join-Path $configDir $commandsFile $settingsPath = Join-Path $configDir $settingsFile $projectPath = Join-Path (Get-Location).ProviderPath $projectCommandsFile # Configuration $settings = Get-Content $settingsPath -Raw | ConvertFrom-Json $addToHistory = $settings.WriteHistory -ne $false $editableCommands = $settings.EditableCommands -ne $false $useConsoleEditor = $settings.UseConsoleEditor -ne $false # Load shared commands $sharedTree = @( Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json -AsHashtable ) $tree = @() # Load project-specific commands if available if (Test-Path -LiteralPath $projectPath -PathType Leaf) { $projectTree = @( Get-Content -LiteralPath $projectPath -Raw | ConvertFrom-Json -AsHashtable ) $tree += $projectTree $tree += @{ Name = 'System' Children = $sharedTree } } else { # Always include shared commands $tree = $sharedTree } $stack = [System.Collections.Generic.List[object]]::new() $items = $tree $selection = 0 $filter = '' $quit = $false $selectedCommand = $null # Enter alternate screen and hide cursor Write-Host -NoNewline "`e[?1049h`e[?25l" try { while (-not $quit) { $visible = @($items) if ($selection -ge $visible.Count) { $selection = [Math]::Max(0, $visible.Count - 1) } Clear-Host Write-Host ' COMMAND NAVIGATOR' -ForegroundColor Cyan $breadcrumb = if ($stack.Count) { ($stack | ForEach-Object { $_.Name }) -join ' / ' } else { 'Home' } Write-Host " $breadcrumb" -ForegroundColor DarkCyan Write-Host ' ------------------------------------------------' if ($filter) { Write-Host " Filter: $filter" -ForegroundColor Yellow } if (-not $visible.Count) { Write-Host ' (No matches. Press / to search again.)' -ForegroundColor DarkGray } for ($i = 0; $i -lt $visible.Count; $i++) { $node = $visible[$i] $prefix = if ($node.ContainsKey('Children')) { '[+]' } else { ' > ' } $line = " $prefix $($node.Name)" if ($i -eq $selection) { $esc = [char]27 Write-Host "$esc[30;46m >$line $esc[0m" } else { Write-Host " $line" -ForegroundColor Gray } } Write-Host '' if ($visible.Count -and -not $visible[$selection].ContainsKey('Children')) { Write-Host ' Command:' -ForegroundColor DarkCyan Write-Host " $($visible[$selection].Command)" -ForegroundColor Yellow } Write-Host '' Write-Host ' Up/Down navigate Enter/Right open or run' Write-Host ' Left parent Esc or Q exit' $key = [Console]::ReadKey($true) switch ($key.Key) { 'UpArrow' { $selection = [Math]::Max(0, $selection - 1) } 'DownArrow' { $selection = [Math]::Min([Math]::Max(0, $visible.Count - 1), $selection + 1) } 'LeftArrow' { if ($stack.Count) { $stack.RemoveAt($stack.Count - 1) $items = $tree foreach ($parent in $stack) { $items = $parent.Children } $selection = 0 $filter = '' } } 'Backspace' { if ($stack.Count) { $stack.RemoveAt($stack.Count - 1) $items = $tree foreach ($parent in $stack) { $items = $parent.Children } $selection = 0 $filter = '' } } { $_ -in @('Enter', 'RightArrow') } { if ($visible.Count) { $node = $visible[$selection] if ($node.ContainsKey('Children')) { $stack.Add($node) $items = $node.Children $selection = 0 $filter = '' } elseif ($key.Key -eq 'Enter') { $selectedCommand = [string]$node.Command $quit = $true } } } 'Escape' { $quit = $true } 'Q' { $quit = $true } } } } finally { # Restore normal screen and show cursor Write-Host -NoNewline "`e[?25h`e[?1049l" } if ($selectedCommand) { $command = [string]$selectedCommand # Determine if the command is editable based on the node's 'Editable' property or the default editable commands list $editable = if ($node.Contains('Editable') -and $null -ne $node.Editable) { $node.Editable } else { $editableCommands } # Check if the command is editable before entering the editing loop if ($editable) { Write-Host 'Edit command, then press Enter to execute. Esc to cancel.' -ForegroundColor DarkGray $cursor = $command.Length $prompt = 'PS> ' $startX = 0 $startY = [Console]::CursorTop $previousLength = 0 while ($true) { $width = [Console]::BufferWidth # Ensure there is enough space for the command to wrap. $totalLength = $prompt.Length + [Math]::Max($command.Length, $previousLength) $rowsNeeded = [int][Math]::Floor($totalLength / $width) + 1 if ($startY + $rowsNeeded -gt [Console]::BufferHeight) { $scroll = $startY + $rowsNeeded - [Console]::BufferHeight [Console]::SetCursorPosition(0, [Console]::BufferHeight - 1) [Console]::Write("`n" * $scroll) $startY -= $scroll } # Redraw the full command, including wrapped lines. [Console]::SetCursorPosition($startX, $startY) [Console]::Write( $prompt + $command + (' ' * [Math]::Max(0, $previousLength - $command.Length)) ) $previousLength = $command.Length # Calculate cursor position across wrapped lines. $position = $startX + $prompt.Length + $cursor $x = $position % $width $y = $startY + [int][Math]::Floor($position / $width) [Console]::SetCursorPosition($x, $y) # Keep your existing keyboard handling below. $key = [Console]::ReadKey($true) switch ($key.Key) { 'Enter' { break } 'Escape' { Write-Host '' return } 'Backspace' { if ($cursor -gt 0) { $command = $command.Remove($cursor - 1, 1) $cursor-- } } 'Delete' { if ($cursor -lt $command.Length) { $command = $command.Remove($cursor, 1) } } 'LeftArrow' { if ($cursor -gt 0) { $cursor-- } } 'RightArrow' { if ($cursor -lt $command.Length) { $cursor++ } } 'Home' { $cursor = 0 } 'End' { $cursor = $command.Length } default { if (-not [char]::IsControl($key.KeyChar)) { $command = $command.Insert( $cursor, [string]$key.KeyChar ) $cursor++ } } } if ($key.Key -eq 'Enter') { break } } Write-Host '' } if ([string]::IsNullOrWhiteSpace($command)) { return } if ($command -match '<[^>]+>') { Write-Warning 'Replace command placeholders before executing.' return } # Execute the command within a try-catch block to handle errors gracefully try { # Add the command to the history if required if ($addToHistory) { [Microsoft.PowerShell.PSConsoleReadLine]::AddToHistory($command) } & ([scriptblock]::Create($command)) } catch { Write-Error $_ } } } function Edit-ProjectCommands { $path = "./$projectCommandsFile" if (-not (Test-Path $path)) { Copy-Item -Path (Join-Path $PSScriptRoot 'projectCommands.json') -Destination $path } Open-File -path $path } function Open-SharedCommands { Open-File -path $configPath } function Open-Settings { Open-File -path $settingsPath } function Open-File(){ param ( [string]$path ) if (Test-Path $path) { if($useConsoleEditor) { edit $path } else { Start-Process -FilePath $path -Verb Open } } } function Open-ConfigFolder { Start-Process -FilePath $configDir } function Remove-ConfigFolder { if (Test-Path $configDir) { Remove-Item -LiteralPath $configDir -Recurse -Force } } # Export the function from the module Export-ModuleMember -Function cmdnav |