Tools/scoop.ps1

# Companion for scoop — dot-sourced by Register-DFTool when scoop is registered.
# Invoke-Expression is required by scoop-search's hook registration pattern.
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '')]
param()

# Scoop requires git for bucket operations (scoop bucket add, scoop update, etc.)
if (-not (Get-Command git -ErrorAction Ignore)) {
    Write-Warning 'DotForge: git is not installed — scoop bucket operations will fail. Fix: scoop install git'
}

# If scoop-search is installed, hook it as the default search provider.
# It is dramatically faster than built-in scoop search.
# To install: scoop install scoop-search
if (Get-Command scoop-search -ErrorAction Ignore) {
    # scoop-search --hook emits `function scoop { ... }` with no scope modifier.
    # This companion is dot-sourced from inside Register-DFTool, so an unqualified
    # function definition lands in that function's scope and is discarded when it
    # returns — the hook never reaches the prompt. (zoxide avoids this because its
    # init emits global:-scoped functions.) Promote the hook's scoop function to
    # global scope so it survives.
    $_scoopHook = & scoop-search --hook | Out-String
    # If scoop-search ever emits a global-scoped function itself (proposed
    # upstream), leave it untouched. Otherwise the Replace forces global scope;
    # it no-ops (leaving the function local, i.e. the pre-fix behavior) if the
    # codegen changes in some other way.
    if ($_scoopHook -notmatch 'function\s+global:scoop\b') {
        $_scoopHook = $_scoopHook.Replace('function scoop {', 'function global:scoop {')
    }
    Invoke-Expression $_scoopHook
} else {
    Write-Verbose 'DotForge: scoop-search not found — install for faster search: scoop install scoop-search'
}

# ── Fuzzy pickers (sins / srm / sup) ─────────────────────────────────────────
# Built on Invoke-DFPicker + fzf, with a live `scoop info` preview pane.
# Search uses scoop-search (fast; matches names AND binaries) when present,
# else the Scoop module's Find-ScoopApp wildcard. Installed list + typed
# install/uninstall/update actions come from the Scoop module (object output,
# no `scoop list` table scraping). fzf --bind execute() runs in a cmd subshell
# that cannot call cmdlets, so the in-place keys use the `scoop` CLI.
# Previews are prefixed with `ping -n 2 127.0.0.1 >nul &` — a ~1s cmd sleep
# that debounces the preview: fzf kills the running preview command when the
# cursor moves, so scrolling fast never spawns `scoop info` for skipped items.

# Guard: the pickers need the Scoop module (Get-ScoopApp + *-ScoopApp actions).
function global:Assert-DFScoopModule {
    if (Get-Command Get-ScoopApp -ErrorAction Ignore) { return $true }
    Write-Warning "DotForge: the 'Scoop' module is required for the scoop pickers (sins/srm/sup). Install it with: Install-Module Scoop -Scope CurrentUser"
    return $false
}

function global:Select-ScoopPackage {
    [CmdletBinding()]
    param([string]$Query = '')

    if (-not (Assert-DFScoopModule)) { return }
    if (-not $Query) { $Query = Read-Host 'Search scoop apps' }

    # Prefer scoop-search (fast, matches binaries); fall back to the module.
    $items = @(
        if (Get-Command scoop-search -ErrorAction Ignore) {
            $bucket = ''
            & scoop-search $Query 2>$null | ForEach-Object {
                if ($_ -match "^'([^']+)' bucket:") { $bucket = $Matches[1]; return }
                if ($_ -match '^\s+(\S+)\s+\(([^)]+)\)') {
                    ('{0,-34} {1,-14} {2}' -f $Matches[1], $Matches[2], $bucket) + "`t" + $Matches[1]
                }
            }
        }
        else {
            Find-ScoopApp -Name "*$Query*" 2>$null | ForEach-Object {
                ('{0,-34} {1,-14} {2}' -f $_.Name, $_.Version, $_.Source) + "`t" + $_.Name
            }
        }
    )

    $sel = Invoke-DFPicker `
        -List          { $items }.GetNewClosure() `
        -Delimiter     "`t" `
        -WithNth       '1' `
        -Preview       'ping -n 2 127.0.0.1 >nul & scoop info {2}' `
        -PreviewWindow 'right:60%' `
        -Header        'scoop search [Enter=command | Alt-R=install | Alt-I=install in place]' `
        -Parse         { ($_ -split "`t")[1] } `
        -Expect        'alt-r' `
        -Bind          'alt-i:execute(scoop install {2})'

    if (-not $sel) { return }
    $name = @($sel.Selected)[0]
    if (-not $name) { return }

    if ($sel.Key -eq 'alt-r') {
        Write-Host "⚙ Installing $name…" -ForegroundColor Cyan
        Install-ScoopApp -Name $name
    }
    else {
        "scoop install $name"
    }
}
Set-Alias -Name sins -Value Select-ScoopPackage -Scope Global -Force

function global:Remove-ScoopPackage {
    [CmdletBinding()]
    param()

    if (-not (Assert-DFScoopModule)) { return }

    $sel = Invoke-DFPicker `
        -List {
            Get-ScoopApp 2>$null | ForEach-Object {
                ('{0,-34} {1,-18} {2}' -f $_.Name, $_.Version, $_.Source) + "`t" + $_.Name
            }
        } `
        -Delimiter     "`t" `
        -WithNth       '1' `
        -Preview       'ping -n 2 127.0.0.1 >nul & scoop info {2}' `
        -PreviewWindow 'right:60%' `
        -Header        'scoop uninstall [Enter=uninstall | Alt-X=uninstall in place | Alt-C=command]' `
        -Parse         { ($_ -split "`t")[1] } `
        -Expect        'alt-c' `
        -Bind          'alt-x:execute(scoop uninstall {2})'

    if (-not $sel) { return }
    $name = @($sel.Selected)[0]
    if (-not $name) { return }

    if ($sel.Key -eq 'alt-c') {
        "scoop uninstall $name"
    }
    else {
        Write-Host "⚙ Uninstalling $name…" -ForegroundColor DarkYellow
        Uninstall-ScoopApp -Name $name
    }
}
Set-Alias -Name srm -Value Remove-ScoopPackage -Scope Global -Force

function global:Invoke-ScoopUpdate {
    [CmdletBinding()]
    param()

    if (-not (Assert-DFScoopModule)) { return }

    # The Scoop module has no "outdated" query, so list all installed apps and
    # let the user mark which to update.
    $sel = Invoke-DFPicker `
        -List {
            Get-ScoopApp 2>$null | ForEach-Object {
                ('{0,-34} {1,-18} {2}' -f $_.Name, $_.Version, $_.Source) + "`t" + $_.Name
            }
        } `
        -Delimiter     "`t" `
        -WithNth       '1' `
        -Multi `
        -Preview       'ping -n 2 127.0.0.1 >nul & scoop info {2}' `
        -PreviewWindow 'right:60%' `
        -Header        'scoop update [Tab=mark | Enter=update marked | Alt-A=update all]' `
        -Parse         { ($_ -split "`t")[1] } `
        -Expect        'alt-a'

    if (-not $sel) { return }

    if ($sel.Key -eq 'alt-a') {
        Write-Host '⚙ Updating all apps…' -ForegroundColor Green
        scoop update '*'
        return
    }

    foreach ($name in $sel.Selected) {
        if ($name) {
            Write-Host "⚙ Updating $name…" -ForegroundColor Green
            Update-ScoopApp -Name $name
        }
    }
}
Set-Alias -Name sup -Value Invoke-ScoopUpdate -Scope Global -Force

# ── PSReadLine binding: Ctrl+G, S ───────────────────────────────────────────
# Type a search term, press Ctrl+G then S: pick in fzf, and the install command
# lands on the command line (editable — press Enter to run). Uses the current
# line as the query. Guarded so it is a no-op when PSReadLine is unavailable.
if (Get-Command Set-PSReadLineKeyHandler -ErrorAction Ignore) {
    Set-PSReadLineKeyHandler -Chord 'Ctrl+g,s' `
        -Description 'DotForge: scoop search → install command onto the line' `
        -ScriptBlock {
            $line = $null; $cursor = $null
            [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor)
            if ([string]::IsNullOrWhiteSpace($line)) { return }
            $cmd = Select-ScoopPackage -Query $line
            if ($cmd -is [string] -and $cmd.Trim()) {
                [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine()
                [Microsoft.PowerShell.PSConsoleReadLine]::Insert($cmd)
            }
        }
}