PSReadLine.ps1

# PSReadLine.ps1 - Define custom key handlers for PSReadLine to enhance command line editing and history management.
# This script is imported by Cool.psm1 to set up the necessary key handlers and options in PSReadLine for the Cool module.

function Convert-MultiDots {
    param([string]$InputString)
    if ([string]::IsNullOrWhiteSpace($InputString)) { return $InputString }
    # Regex logic:
    # (?<=^|[\\/]) : Preceded by start of string or a slash
    # \.{3,} : Match three or more dots
    # (?=[\\/]|$) : Followed by a slash or end of string
    return [Regex]::Replace($InputString, '(?<=^|[\\/])\.{3,}(?=[\\/]|$)', {
            param($m)
            $dotsCount = $m.Value.Length
            # Default slash style: use \ if the path contains backslashes, otherwise use /
            $sep = if ($InputString -match '\\') { '\' } else { '/' }
            $ups = @('..') * ($dotsCount - 1)
            return $ups -join $sep
        })
}

$script:PSRL = [Microsoft.PowerShell.PSConsoleReadLine]

function Get-InputFromPSReadLine {
    $line = $null
    $cursor = $null
    # Get the current command line and cursor position
    $script:PSRL::GetBufferState([ref]$line, [ref]$cursor)
    if (-not [string]::IsNullOrWhiteSpace($line)) {
        return $line
    }
    return $null
}

function DeleteFromHistory {
    # Yes, this is a hack, but PSReadLine does not provide a direct API to check dropdown visibility
    $options = $script:PSRL::GetOptions()
    $isPredictorOn = ($options.PredictionSource -ne 'None')
    
    $line = Get-InputFromPSReadLine
    # Only proceed if there is a non-empty command line
    if ($null -ne $line) {
        $target = $line.Trim()
        $historyPath = $options.HistorySavePath
        if ([System.IO.File]::Exists($historyPath)) {
            # remove the target command from history content
            $newContent = [System.IO.File]::ReadAllLines($historyPath) | Where-Object { $_ -ne $target }
            
            # move original history file to a backup location to prevent conflicts during clearing
            Move-Item $historyPath "$historyPath.bak" -Force -ErrorAction SilentlyContinue
            
            # Clear history using PSReadLine API
            $script:PSRL::RevertLine()
            try {
                $script:PSRL::ClearHistory()
                
                # Rebuild history file and memory cache using AddToHistory
                if ($null -ne $newContent) {
                    foreach ($h in $newContent) { 
                        $script:PSRL::AddToHistory($h) 
                    }
                }
                # Cleanup backup file
                Remove-Item "$historyPath.bak" -ErrorAction SilentlyContinue
            }
            catch {
                # In case of any error, restore the original history file to prevent data loss
                Move-Item "$historyPath.bak" $historyPath -Force -ErrorAction SilentlyContinue
            }

            # If predictor is on, we need to trigger it to refresh its cache after history change
            if ($isPredictorOn) {
                # Trigger a dummy input to refresh predictor cache
                $script:PSRL::Insert(' ')
                $script:PSRL::BackwardDeleteChar()
            }
        }
    }
}

function SaveInHistory {
    $line = Get-InputFromPSReadLine
    # Only proceed if there is a non-empty command line
    if ($null -ne $line) {
        $target = $line.Trim()
        $script:PSRL::AddToHistory($target)
    }
    $script:PSRL::RevertLine()
}

function SmartDirectoryNavigation {
    $line = Get-InputFromPSReadLine
    if ($null -eq $line) {
        $script:PSRL::AcceptLine()
        return
    }

    # Parse the input line to analyze its structure.
    # We will check if it's a simple string that can be treated as a directory path for quick navigation.
    $errors = $null
    $tokens = $null
    $ast = [System.Management.Automation.Language.Parser]::ParseInput($line, [ref]$tokens, [ref]$errors)

    if (-not $errors) {
        $pipeline = $ast.EndBlock.Statements
        if ($pipeline.Count -eq 1 -and $pipeline[0] -is [System.Management.Automation.Language.PipelineAst]) {
            $command = $pipeline[0].PipelineElements
            if ($command.Count -eq 1 -and $command[0] -is [System.Management.Automation.Language.CommandExpressionAst]) {
                $expression = $command[0].Expression
                $potentialPath = $null
                if ($expression -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
                    $potentialPath = $expression.Value
                }
                elseif ($expression -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) {
                    try {
                        $potentialPath = $ExecutionContext.InvokeCommand.ExpandString($expression.Value)
                    }
                    catch {
                        $potentialPath = $null
                    }
                }
                if ($null -ne $potentialPath) {
                    try {
                        $potentialPath = Convert-MultiDots $potentialPath
                        $absPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($potentialPath)
                        if ([System.IO.Directory]::Exists($absPath)) {
                            $script:PSRL::Replace(0, $line.Length, $absPath)
                        }
                    }
                    catch {}
                }
            }
        }
    }
    $script:PSRL::AcceptLine()
}

function EnhancedMenuComplete {
    $line = $null
    $cursor = $null
    # We get the current buffer state to determine the word at the cursor position
    # for potential multi-dot conversion before triggering completion.
    $script:PSRL::GetBufferState([ref]$line, [ref]$cursor)

    # 1. Find the start position of the "word" at the cursor
    # We consider space, semicolon, and quotes as word delimiters for simplicity,
    # which covers most cases for file paths and commands.
    $firstHalf = $line.SubString(0, $cursor)
    $lastSpaceIndex = $firstHalf.LastIndexOfAny(@(' ', ';', '"', "'"))
    $startIndex = $lastSpaceIndex + 1
    $currentWord = $firstHalf.SubString($startIndex)

    # 2. Only perform multi-dot conversion on the current "word"
    # under the cursor to avoid unintended changes to the entire line.
    $converted = Convert-MultiDots $currentWord

    # 3. If there is a change, only replace the current word in the buffer,
    # preserving the rest of the line and cursor position.
    if ($converted -ne $currentWord) {
        $script:PSRL::Replace($startIndex, $currentWord.Length, $converted)
    }

    # 4. Execute system completion after potential conversion,
    # which will now work with the updated word under the cursor.
    $script:PSRL::MenuComplete()
}

function TabExpansion2 {
    [CmdletBinding()]
    param([string]$inputScript, [int]$cursorColumn, $hashtable)
    # 1. Get the original completion results from the system
    $ret = [System.Management.Automation.CommandCompletion]::CompleteInput($inputScript, $cursorColumn, $hashtable)
    
    # If there are no matches, return the original result without modification
    if ($null -eq $ret -or $ret.CompletionMatches.Count -eq 0) { return $ret }

    $newMatches = New-Object System.Collections.Generic.List[System.Management.Automation.CompletionResult]

    foreach ($result in $ret.CompletionMatches) {
        $added = $false
        # 2. Only apply "beautification" for filesystem paths
        if ($result.ResultType -in @('ProviderContainer', 'ProviderItem')) {
            $item = Get-Item -LiteralPath $result.ToolTip -Force -ErrorAction SilentlyContinue
            # In some cases, certain files may not be accessible via Get-Item (e.g., due to permission issues).
            # In such cases, we attempt to retrieve the same-named file from its parent directory using Get-ChildItem
            # to bypass permission issues, allowing us to at least beautify its name.
            if ($null -eq $item) {
                $parentPath = [System.IO.Path]::GetDirectoryName($result.ToolTip)
                $fileName = [System.IO.Path]::GetFileName($result.ToolTip)
                if ($parentPath -and (Test-Path -LiteralPath $parentPath)) {
                    $item = Get-ChildItem -LiteralPath $parentPath -Filter $fileName -Force -ErrorAction SilentlyContinue
                }
            }
            $listItemText = Format-CoolName -Item $item
            $newMatches.Add([System.Management.Automation.CompletionResult]::new(
                    $result.CompletionText,
                    $listItemText, # Menu display colored text
                    $result.ResultType,
                    $result.ToolTip
                ))
            $added = $true
        }
        # 3. Fallback logic: if the item wasn't beautified, add the original result back
        if (-not $added) {
            $newMatches.Add($result)
        }
    }

    # 4. Key step: reconstruct and return the CommandCompletion object
    return [System.Management.Automation.CommandCompletion]::new(
        $newMatches,
        $ret.CurrentMatchIndex,
        $ret.ReplacementIndex,
        $ret.ReplacementLength
    )
}

# This script sets up custom hotkeys in PSReadLine for enhanced command line editing and history management.
# Set up command prediction to use history and display predictions in a list view style.
if ($host.Name -eq 'ConsoleHost' -and $Host.UI.SupportsVirtualTerminal) {
    try {
        if ($PSVersionTable.PSVersion -ge [Version]"7.2") {
            # Enable command prediction based on history
            Set-PSReadLineOption -PredictionSource HistoryAndPlugin -ErrorAction Stop
        }
        else {
            # For older versions, just use history for prediction
            Set-PSReadLineOption -PredictionSource History -ErrorAction Stop
        }

        # Set the prediction view style to ListView for better visibility of suggestions.
        Set-PSReadLineOption -PredictionViewStyle ListView -ErrorAction Stop

        # Configure the Tab key to trigger menu completion, which allows cycling through possible completions in a dropdown menu.
        Set-PSReadLineKeyHandler -Key "Tab" -Function MenuComplete -ErrorAction Stop

        # Configure Up and Down arrow keys to move the cursor to the end of the line when searching history, which is more intuitive for most users.
        Set-PSReadLineOption -HistorySearchCursorMovesToEnd -ErrorAction Stop

        # Set Up and Down arrow keys to search through command history based on the current input, similar to typical shell behavior.
        Set-PSReadLineKeyHandler -Key UpArrow -Function HistorySearchBackward -ErrorAction Stop
        Set-PSReadLineKeyHandler -Key DownArrow -Function HistorySearchForward -ErrorAction Stop

        # This script sets up a custom hotkey (Alt+Delete) in PSReadLine to delete the current command line from history.
        # It works by directly manipulating the history file and then refreshing the PSReadLine cache.
        Set-PSReadLineKeyHandler -Chord 'Alt+Delete' `
            -BriefDescription "DeleteFromHistory" `
            -LongDescription "Delete the current command line from history" `
            -ScriptBlock { try { DeleteFromHistory } catch { } } `
            -ErrorAction Stop

        # This script sets up a custom hotkey (Alt+s) in PSReadLine to save the current command line to history without executing it.
        # It retrieves the current command line, adds it to history, and then reverts the line to allow the user to continue editing or executing it as they wish.
        Set-PSReadLineKeyHandler -Chord 'Alt+s' `
            -BriefDescription "SaveInHistory" `
            -LongDescription "Save the current command line in history but do not execute it" `
            -ScriptBlock { try { SaveInHistory } catch { } } `
            -ErrorAction Stop

        # This script sets up a custom hotkey (Enter) in PSReadLine to implement smart directory navigation.
        # It intercepts the Enter key, checks if the input is a simple path-like string, and if so,
        # it attempts to resolve it as a directory and change to that directory instead of executing it as a command.
        Set-PSReadLineKeyHandler -Key Enter `
            -BriefDescription "SmartDirectoryNavigation" `
            -LongDescription "Navigate to the directory if the input is a valid path, otherwise execute the command" `
            -ScriptBlock { try { SmartDirectoryNavigation } catch { } } `
            -ErrorAction Stop

        # This script sets up a custom hotkey (Tab) in PSReadLine to trigger menu completion with enhanced formatting for filesystem paths.
        # It intercepts the Tab key, checks if the current word under the cursor is a potential filesystem path, applies multi-dot conversion if needed,
        # and then triggers the menu completion to show suggestions with colors and icons.
        Set-PSReadLineKeyHandler -Key Tab `
            -BriefDescription "EnhancedMenuComplete" `
            -LongDescription "Trigger menu completion with enhanced formatting for filesystem paths" `
            -ScriptBlock { try { EnhancedMenuComplete } catch { } } `
            -ErrorAction Stop
    }
    catch {
        # If PSReadLine is not available or any error occurs,
        # we can safely ignore it as these hotkeys are optional enhancements.
    }
}