DirJump.psm1

# DirJump: PowerShell side (prompt hook + key handlers). The predictor itself lives in DirJump.dll.
#
# Keys (each bound only if not already bound to something else):
# Ctrl+j jump to the top DirJump match for what is typed (no history entry)
# Backspace on an empty line cd .. (guarded: never fires as part of deleting text / key repeat)
# Enter, Tab, arrows etc. are untouched: Enter always runs what was typed.

Set-StrictMode -Version Latest

$script:BackspaceGuardMs = 500
$script:LastDeleteTicks = [long]0
$script:BoundKeys = @()

function script:Get-FileSystemLocation {
    $loc = $ExecutionContext.SessionState.Path.CurrentLocation
    if ($loc.Provider.Name -eq 'FileSystem') { $loc.ProviderPath } else { $null }
}

# --- prompt hook -------------------------------------------------------------------------------

$script:OriginalPrompt = $null
$promptCmd = Get-Command -Name prompt -CommandType Function -ErrorAction SilentlyContinue
if ($promptCmd) { $script:OriginalPrompt = $promptCmd.ScriptBlock }

$script:CallerState = $null
$script:LastHistoryId = -1

# Moves made by the key handlers must go through the *global* session state: PowerShell keeps a
# separate `cd -`/`cd +` history per session state, and module code runs in the module's own.
function script:Set-DirJumpLocation([string]$Path) {
    if ($script:CallerState) {
        $sb = [scriptblock]::Create('Set-Location -LiteralPath $args[0]')
        [void]$ExecutionContext.InvokeCommand.InvokeScript($script:CallerState, $sb, $Path)
    } else {
        Set-Location -LiteralPath $Path
    }
}

# Functions and handlers defined here stay bound to the module scope, so $script: works inside them.
function global:prompt {
    [CmdletBinding()] param()
    # The host calls prompt from the global scope, so $PSCmdlet.SessionState is the global session
    # state there. (When PSReadLine re-invokes it from a key handler, the caller is this module: ignore.)
    if ($null -eq $PSCmdlet.SessionState.Module) { $script:CallerState = $PSCmdlet.SessionState }
    $p = Get-FileSystemLocation
    if ($p) {
        # The command line that just ran, if any: lets DirJump recognise a suggestion picked from the list.
        $last = $null
        $h = Get-History -Count 1
        if ($h -and $h.Id -ne $script:LastHistoryId) { $script:LastHistoryId = $h.Id; $last = $h.CommandLine }
        [DirJump.State]::OnPrompt($p, $last)
    }
    $text = if ($script:OriginalPrompt) { & $script:OriginalPrompt } else { "PS $($ExecutionContext.SessionState.Path.CurrentLocation)$('>' * ($NestedPromptLevel + 1)) " }
    # OSC 9;9 lets Windows Terminal open new tabs/panes (and restored sessions) in this directory.
    if ($p) { "$([char]27)]9;9;`"$p`"$([char]27)\" + $text } else { $text }
}

# --- key handlers ------------------------------------------------------------------------------

function script:Test-KeyFree([string]$Chord) {
    -not (Get-PSReadLineKeyHandler -Bound | Where-Object { $_.Key -eq $Chord })
}

# PSReadLine is only loaded in interactive sessions; elsewhere (scripts, tests) skip the keys.
$script:HasPSReadLine = [bool](Get-Module PSReadLine)

if (-not $script:HasPSReadLine) {
    # no key handlers
} elseif (Test-KeyFree 'Ctrl+j') {
    Set-PSReadLineKeyHandler -Chord 'Ctrl+j' -BriefDescription 'DirJump' `
        -Description 'Jump to the best DirJump directory match for the current input' -ScriptBlock {
        $line = $null; $cursor = $null
        [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor)
        $target = [DirJump.State]::Jump($line)   # also tags the move as a jump for this query
        if (-not $target) { return }
        # Clear the typed query. (RevertLine is not used: with the ListView open it only dismisses the list.)
        [Microsoft.PowerShell.PSConsoleReadLine]::Delete(0, $line.Length)
        Set-DirJumpLocation $target
        [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt()
    }
    $script:BoundKeys += 'Ctrl+j'
} else {
    Write-Warning 'DirJump: Ctrl+j is already bound; not binding the jump key.'
}

$bs = if ($script:HasPSReadLine) { Get-PSReadLineKeyHandler -Bound | Where-Object { $_.Key -eq 'Backspace' } }
if (-not $script:HasPSReadLine) {
    # no key handlers
} elseif (-not $bs -or $bs.Function -eq 'BackwardDeleteChar') {
    Set-PSReadLineKeyHandler -Key Backspace -BriefDescription 'DirJumpBackspace' `
        -Description 'Delete the previous character; on an empty line, go to the parent directory' -ScriptBlock {
        param($key, $arg)
        $line = $null; $cursor = $null
        [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor)
        $now = [Environment]::TickCount64
        if ($line.Length -gt 0) {
            [Microsoft.PowerShell.PSConsoleReadLine]::BackwardDeleteChar($key, $arg)
            $script:LastDeleteTicks = $now
            return
        }
        # Guard: a Backspace arriving soon after one that deleted text (e.g. key repeat while
        # clearing the line) is swallowed, and extends the guard while the key is held.
        if ($now - $script:LastDeleteTicks -lt $script:BackspaceGuardMs) {
            $script:LastDeleteTicks = $now
            return
        }
        $loc = $ExecutionContext.SessionState.Path.CurrentLocation
        if ($loc.Provider.Name -ne 'FileSystem') { return }
        # (Split-Path can't combine -LiteralPath with -Parent.) Null at a drive root.
        $parent = [System.IO.Path]::GetDirectoryName($loc.ProviderPath)
        if (-not $parent) { return }
        [DirJump.State]::SetPending('up', $null)
        Set-DirJumpLocation $parent
        [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt()
    }
    $script:BoundKeys += 'Backspace'
} else {
    Write-Warning "DirJump: Backspace is bound to $($bs.Function); not adding cd-on-empty-line."
}

# --- commands ----------------------------------------------------------------------------------

function Get-DirJumpStatus {
    <# .SYNOPSIS Show DirJump's indexes, history file and ignore configuration. #>
    [DirJump.State]::Status()
}

function Get-DirJumpMatch {
    <# .SYNOPSIS Show how DirJump ranks matches for a query, with the score breakdown. #>
    param([Parameter(Mandatory, Position = 0)][string]$Query, [int]$First = 10)
    [DirJump.State]::Query($Query, $First) | Select-Object Display, Score, Explanation, FullPath
}

function Update-DirJumpIndex {
    <# .SYNOPSIS Rebuild the directory indexes now (they also refresh automatically). #>
    param([switch]$Wait)
    [DirJump.State]::Reindex()
    $p = Get-FileSystemLocation
    if ($p) { [DirJump.State]::OnPrompt($p) }
    if ($Wait) { [void][DirJump.State]::WaitForIndex(60000) }
}

# --- init / cleanup ----------------------------------------------------------------------------

$start = Get-FileSystemLocation
if (-not $start) { $start = $HOME }
[DirJump.State]::Initialize($start)

$ExecutionContext.SessionState.Module.OnRemove = {
    foreach ($k in $script:BoundKeys) { Remove-PSReadLineKeyHandler -Chord $k }
    if ($script:BoundKeys -contains 'Backspace') {
        Set-PSReadLineKeyHandler -Key Backspace -Function BackwardDeleteChar
    }
    if ($script:OriginalPrompt) { Set-Item -Path function:global:prompt -Value $script:OriginalPrompt }
}

Export-ModuleMember -Function Get-DirJumpStatus, Get-DirJumpMatch, Update-DirJumpIndex