Public/Invoke-DFPicker.ps1

#Requires -Version 7.0

function Invoke-DFPicker {
    <#
    .SYNOPSIS
        Generalized fzf picker. Handles list -> fzf -> parse -> action skeleton.
    .PARAMETER List
        Scriptblock that produces the items to display in fzf, one per line.
        Objects are converted to strings. Required.
    .PARAMETER Header
        Header text shown at the top of the fzf window.
    .PARAMETER Preview
        fzf --preview string. Use {} as placeholder for the selected item.
    .PARAMETER PreviewWindow
        fzf --preview-window value. Default: 'right:60%'.
    .PARAMETER Ansi
        Pass --ansi to fzf (for ANSI-colored input).
    .PARAMETER Multi
        Pass --multi to fzf so Tab marks several items; -Action is called once
        per selected item (or each is returned).
    .PARAMETER Delimiter
        fzf --delimiter value.
    .PARAMETER WithNth
        fzf --with-nth value (which fields to display).
    .PARAMETER Parse
        Scriptblock to transform the raw fzf output line. $_ is the raw line.
        If omitted, the raw line is used as-is.
    .PARAMETER Action
        Scriptblock invoked with the parsed value as param($v).
        If omitted, the parsed value is written to the output stream.
        Ignored when -Expect is used (the caller branches on the returned .Key).
    .PARAMETER Expect
        fzf --expect keys (e.g. 'alt-r','alt-i'). When set, fzf reports which key
        the user pressed and the picker switches to "multi-key" mode: instead of
        returning/acting on the parsed value, it returns a single object
        [pscustomobject]@{ Key = <pressed key>; Selected = @(<parsed items>) }.
        The pressed key is '' when the user accepted with Enter. -Action is not
        invoked in this mode.
    .PARAMETER Bind
        fzf --bind specs (e.g. 'alt-i:execute(winget install --id {2})'). Each
        entry is passed as its own --bind. Use for act-in-place bindings that run
        a command while fzf stays open.
    .PARAMETER FzfArgs
        Extra fzf arguments passed through verbatim (appended last). Escape hatch
        for fzf flags this function does not model directly.
    .DESCRIPTION
        Runs the list -> fzf -> parse -> action skeleton that every DotForge
        picker is built on: evaluates -List, pipes the items to fzf with the
        given flags, optionally transforms each selected line with -Parse, then
        passes it to -Action or returns it on the output stream. Pressing Esc in
        fzf returns nothing and runs no action.

        The picker executable is fzf, or the one named by $Env:Picker (any
        fzf-compatible picker, e.g. 'sk' for skim). If it is not on PATH, the
        call throws a terminating error.

        A -List scriptblock that uses local variables from the caller must be
        bound with .GetNewClosure() (e.g. { $topics }.GetNewClosure()); without
        it the variables resolve in the wrong scope and the list is empty.

        Uses the private Invoke-DFFzf wrapper so tests can mock fzf without
        spawning a real process. Side effects: starts the picker process, plus
        whatever -Action, -Preview and -Bind commands do.
    .EXAMPLE
        Invoke-DFPicker -List { git branch } -Header 'Select branch' -Parse { $_.TrimStart('*').Trim() } -Action { param($b) git checkout $b }

        Fuzzy-selects a git branch and checks it out. -Parse strips the '* '
        marker and indentation that git branch prints.
    .EXAMPLE
        $file = Invoke-DFPicker -List { Get-ChildItem -Name } -Preview 'type {}'

        Fuzzy-selects a file from the current directory, previewing it with
        cmd.exe's type (fzf runs preview commands through cmd.exe on Windows),
        and returns the selected name (nothing if you press Esc).
    .EXAMPLE
        $r = Invoke-DFPicker -List { Get-Process | ForEach-Object { "$($_.Name)`t$($_.Id)" } } `
            -Delimiter "`t" -WithNth 1 -Expect 'alt-k' -Parse { ($_ -split "`t")[1] }
        if ($r.Key -eq 'alt-k') { Stop-Process -Id $r.Selected -WhatIf } else { Get-Process -Id $r.Selected }

        Shows process names while carrying the id in a hidden field. Enter shows
        the process; Alt-K previews stopping it. -Expect makes both keys select
        the same item but drive different actions.
    .OUTPUTS
        System.String — selected (and optionally parsed) item when -Action is omitted.
        None — when -Action is provided (side-effect only).
        System.Management.Automation.PSCustomObject — { Key; Selected } when -Expect is used.
    .LINK
        https://github.com/simsrw73/DotForge/blob/main/docs/guide/pickers-and-helpers.md
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][scriptblock]$List,
        [string]$Header        = '',
        [string]$Preview       = '',
        [string]$PreviewWindow = 'right:60%',
        [switch]$Ansi,
        [switch]$Multi,
        [string]$Delimiter     = '',
        [string]$WithNth       = '',
        [scriptblock]$Parse,
        [scriptblock]$Action,
        [string[]]$Expect,
        [string[]]$Bind,
        [string[]]$FzfArgs
    )

    $pickerArgs = [System.Collections.Generic.List[string]]@('--preview-window', $PreviewWindow)
    if ($Preview)   { $pickerArgs.AddRange([string[]]@('--preview',   $Preview)) }
    if ($Header)    { $pickerArgs.AddRange([string[]]@('--header',    $Header)) }
    if ($Ansi)      { $pickerArgs.Add('--ansi') }
    if ($Multi)     { $pickerArgs.Add('--multi') }
    if ($Delimiter) { $pickerArgs.AddRange([string[]]@('--delimiter', $Delimiter)) }
    if ($WithNth)   { $pickerArgs.AddRange([string[]]@('--with-nth',  $WithNth)) }
    if ($Expect)    { $pickerArgs.AddRange([string[]]@('--expect', ($Expect -join ','))) }
    foreach ($b in $Bind) { $pickerArgs.AddRange([string[]]@('--bind', $b)) }
    if ($FzfArgs)   { $pickerArgs.AddRange([string[]]$FzfArgs) }

    $items = @(& $List)
    $selected = Invoke-DFFzf -InputItems $items -FzfArgs $pickerArgs
    if (-not $selected) { return }

    if ($Expect) {
        # fzf prints the pressed key on the first line (empty string for Enter),
        # then the selection(s). Return both so the caller can branch on the key.
        $lines  = @($selected)
        $key    = $lines[0]
        $picked = @($lines | Select-Object -Skip 1 | ForEach-Object {
            if ($Parse) { $_ | ForEach-Object $Parse } else { $_ }
        })
        return [pscustomobject]@{ Key = $key; Selected = $picked }
    }

    foreach ($item in @($selected)) {
        $value = if ($Parse) { $item | ForEach-Object $Parse } else { $item }
        if ($Action) { & $Action $value } else { $value }
    }
}