src/UI/20-Submenu.ps1

# 20-Submenu.ps1 — per-module actions: details, load/unload/install, version
# pinning, duplicate cleanup, auth status/disconnect (#32), add-unmanaged
# (#27), edit/delete/move, command browsing.

# Where this module actually lives (gh#3). "Which copy is being used, and from
# where?" is the first question when a module misbehaves, and until now the
# screen could not answer it. Pure formatting: the paths and the search-root
# verdict were resolved once by Resolve-PSMMModuleFacts - the render path
# touches neither disk nor $env:PSModulePath.
function script:Get-PSMMModuleLocationFacts {
    param(
        [Parameter(Mandatory)] $Entry,
        $Manifest
    )
    $rows = [System.Collections.Generic.List[string[]]]::new()
    $vers = @($Entry.InstalledVersions)
    if (-not $vers.Count) { return @($rows) }
    $mid = [char]0x00B7
    # The path is shown WHOLE - truncating it threw away the module and
    # version folders, i.e. the answer. Pre-wrapped at separator boundaries so
    # the grid never has to reflow it (left to itself it ragged-wraps the path
    # and blows the label column out to ~60 chars).
    $cap = [Math]::Max(30, (Get-PSMMWinSize).Width - 22)
    $addWrapped = {
        param($Label, $Text, $Suffix)
        $lines = @(Get-PSMMWrapPath -Path "$Text" -Width $cap)
        for ($i = 0; $i -lt $lines.Count; $i++) {
            $tail = if ($i -eq $lines.Count - 1 -and $Suffix) { " $mid $Suffix" } else { '' }
            $rows.Add(@($(if ($i -eq 0) { $Label } else { '' }),
                    "[$script:PSMM_ColDim]$(ConvertTo-PSMMSafe $lines[$i])[/]$tail"))
        }
    }
    & $addWrapped 'path' "$($vers[0].Path)" $null
    if ($Manifest -and $Manifest.Root) {
        $notes = @()
        if ($null -ne $Manifest.RootOrder) {
            $notes += "search order $($Manifest.RootOrder + 1)"
            if ($Manifest.RootOneDrive) { $notes += "[$script:PSMM_ColWarn]onedrive[/]" }
        } else {
            $notes += "[$script:PSMM_ColWarn]not on the module search path[/]"
        }
        & $addWrapped 'location' "$($Manifest.Root)" ($notes -join " $mid ")
    }
    if ($vers.Count -gt 1) {
        $shown = @($vers | Select-Object -First 4 | ForEach-Object {
                "v$(Get-PSMMVersionMarkup -Version $_.Version -Prerelease $_.Prerelease) [$script:PSMM_ColDim]$($_.Scope)[/]"
            })
        if ($vers.Count -gt 4) { $shown += "[$script:PSMM_ColDim]+$($vers.Count - 4) more[/]" }
        $rows.Add(@('versions', ($shown -join " $mid ")))
    }
    @($rows)
}

# Everything the module menu needs that costs I/O: manifest facts (author,
# project URL, type, command count), which search root the module sits under,
# and how many of its files are still cloud-only placeholders. Resolved ONCE
# when the screen opens - the render path must stay free of disk and network
# access (same rule the grid follows).
function script:Resolve-PSMMModuleFacts {
    param([Parameter(Mandatory)] $Entry)
    $facts = [pscustomobject]@{
        Author = ''; ProjectUri = ''; ModuleType = ''; CommandCount = 0; CloudOnly = 0
        Root = ''; RootOrder = $null; RootOneDrive = $false
        Requires = ''; Exports = ''; Bytes = 0L; InstalledAt = $null
    }
    try {
        $m = Get-Module -Name $Entry.Name -ErrorAction SilentlyContinue | Select-Object -First 1
        if (-not $m -and $Entry.Installed) {
            $m = Get-Module -ListAvailable -Name $Entry.Name -ErrorAction SilentlyContinue |
                Sort-Object Version -Descending | Select-Object -First 1
        }
        if ($m) {
            $facts.Author = "$($m.Author)"
            $facts.ModuleType = "$($m.ModuleType)"
            $n = 0
            try { $n = @($m.ExportedCommands.Keys).Count } catch { }
            $facts.CommandCount = $n
            try {
                $uri = $m.PrivateData.PSData.ProjectUri
                if ($uri -and (Test-PSMMUrl -Text "$uri")) { $facts.ProjectUri = "$uri" }
            } catch { }
            # what the module demands of the host. CompatiblePSEditions in
            # particular is the answer to "why won't this load in pwsh 7".
            $req = @()
            try { if ($m.PowerShellVersion) { $req += "PowerShell $($m.PowerShellVersion)+" } } catch { }
            try {
                $eds = @($m.CompatiblePSEditions)
                if ($eds.Count) { $req += "edition $($eds -join '/')" }
            } catch { }
            $facts.Requires = $req -join " $([char]0x00B7) "
            # How the manifest declares its exports. '*' means PowerShell
            # cannot resolve a command name to this module without importing
            # it first - no auto-loading - which is exactly how gh#2 hid.
            try {
                $d = Import-PowerShellDataFile -Path $m.Path -ErrorAction Stop
                $wild = @('CmdletsToExport', 'FunctionsToExport', 'AliasesToExport' | Where-Object {
                        @($d[$_]) -contains '*'
                    })
                if ($wild.Count) { $facts.Exports = 'wildcard' }
            } catch { }
        }
    } catch { }
    $vers = @($Entry.InstalledVersions)
    if ($vers.Count) {
        try {
            $tree = Get-PSMMModuleTree -ModuleBase "$($vers[0].Path)" -Name $Entry.Name
            $facts.Root = $tree.Root
            $match = @(Get-PSMMModulePathInfo | Where-Object {
                    $_.Path.TrimEnd('\', '/') -eq "$($tree.Root)".TrimEnd('\', '/')
                }) | Select-Object -First 1
            if ($match) { $facts.RootOrder = $match.Order; $facts.RootOneDrive = [bool]$match.OneDrive }
            # how much disk the whole module tree takes, and when it landed -
            # "when did this get here" is a real troubleshooting question
            $facts.Bytes = Get-PSMMFolderSize -Path $tree.Tree
            $facts.InstalledAt = (Get-Item -LiteralPath "$($vers[0].Path)" -ErrorAction Stop).LastWriteTime
        } catch { }
    }
    # only ever scans OneDrive module bases (see Get-PSMMModuleCloudOnlyFile)
    try { $facts.CloudOnly = @(Get-PSMMModuleCloudOnlyFile -Name $Entry.Name).Count } catch { }
    $facts
}

# Build the module menu (mock 2e): breadcrumb header, condensed facts panel
# (what/entry/disk/session/connection), actions grouped by what they touch.
# $Auth may be $null (not yet queried).
function script:Build-PSMMModuleMenuView {
    param(
        [Parameter(Mandatory)] $Entry,
        $Auth,
        [string]$StatusMarkup,
        # manifest facts resolved once by the caller - manifest lookups do not
        # belong in the render path (@{ Author; ProjectUri; ModuleType;
        # CommandCount; Manifest })
        $Manifest
    )
    $isUnmanaged = [bool]$Entry.PSObject.Properties['Unmanaged']
    $mid = [char]0x00B7
    $Author = if ($Manifest) { "$($Manifest.Author)" } else { '' }

    # --- condensed facts -------------------------------------------------
    $what = if (-not [string]::IsNullOrWhiteSpace($Entry.Description)) { ConvertTo-PSMMSafe $Entry.Description }
            elseif ($Entry.FriendlyName -and $Entry.FriendlyName -ne $Entry.Name) { ConvertTo-PSMMSafe $Entry.FriendlyName }
            else { '-' }
    $entryTxt = if ($isUnmanaged) { 'not in any config file (unmanaged)' }
                else {
                    # escape: range pins like '[1.0,2.0)' are invalid Spectre tags
                    $pinTxt = if ($Entry.Version) { "pin $(ConvertTo-PSMMSafe $Entry.Version)$(if ($Entry.PinnedExact) { ' (exact)' } else { ' (range)' })" } else { 'no pin' }
                    $srcLeaf = if ($Entry.Source -eq '<profile inline>') { 'profile inline' } else { Split-Path $Entry.Source -Leaf }
                    $rwTxt = if ($Entry.Writable -and $Entry.Source -ne '<profile inline>') { 'rw' } else { 'ro' }
                    $preTxt = if ($Entry.AllowPrerelease) { " $mid [$script:PSMM_ColInfo]prereleases allowed[/]" } else { '' }
                    "$(Get-PSMMStartupWord $Entry.Mode) at startup $mid upkeep: $(Get-PSMMUpkeepWord $Entry.Install) $mid $pinTxt$preTxt [$script:PSMM_ColDim]$mid $(ConvertTo-PSMMSafe $srcLeaf) ($rwTxt)[/]"
                }
    $disk = if ($Entry.Installed) {
        $vers = @($Entry.InstalledVersions)
        $t = "v$(Get-PSMMVersionMarkup -Version $Entry.InstalledVersion -Prerelease $Entry.InstalledPrerelease) [$script:PSMM_ColDim]$($Entry.InstallScope)[/]"
        if ($Entry.UpdateAvailable -and $Entry.LatestVersion) {
            $t += " [$script:PSMM_ColWarn]$([char]0x21E1) v$(ConvertTo-PSMMSafe (Get-PSMMVersionText -Version $Entry.LatestVersion -Prerelease $Entry.LatestPrerelease)) available[/]"
        }
        if ($vers.Count -gt 1) { $t += " [$script:PSMM_ColWarn]$mid $($vers.Count) versions on disk[/]" }
        $t
    } else { 'not installed' }
    $session = if ($Entry.Loaded) {
        "imported $mid v$(Get-PSMMVersionMarkup -Version $Entry.LoadedVersion -Prerelease $Entry.LoadedPrerelease)$(if ($null -ne $Entry.ImportMs) { " [$script:PSMM_ColDim]$mid import took $($Entry.ImportMs) ms[/]" })"
    } else { 'not imported' }
    $facts = [System.Collections.Generic.List[string[]]]::new()
    $facts.Add(@('what', $what))
    if (-not [string]::IsNullOrWhiteSpace($Author)) {
        $facts.Add(@('by', (ConvertTo-PSMMSafe $Author)))
    }
    $facts.Add(@('entry', $entryTxt))
    $facts.Add(@('disk', $disk))
    $facts.Add(@('session', $session))
    # where it actually lives (gh#3): the first question when a module
    # misbehaves is "which copy is being used, and from where?"
    foreach ($row in (Get-PSMMModuleLocationFacts -Entry $Entry -Manifest $Manifest)) { $facts.Add($row) }
    if ($Manifest) {
        $bits = @()
        if ($Manifest.ModuleType) { $bits += "[$script:PSMM_ColDim]$(ConvertTo-PSMMSafe "$($Manifest.ModuleType)") module[/]" }
        if ($Manifest.CommandCount) { $bits += "[$script:PSMM_ColDim]$($Manifest.CommandCount) command(s) (b browses them)[/]" }
        if ($Manifest.Bytes) { $bits += "[$script:PSMM_ColDim]$(Format-PSMMSize $Manifest.Bytes) on disk[/]" }
        if ($Manifest.InstalledAt) {
            $bits += "[$script:PSMM_ColDim]written $($Manifest.InstalledAt.ToString('yyyy-MM-dd'))[/]"
        }
        if ($bits.Count) { $facts.Add(@('kind', ($bits -join " $mid "))) }
        if ($Manifest.Requires) {
            $facts.Add(@('requires', "[$script:PSMM_ColDim]$(ConvertTo-PSMMSafe $Manifest.Requires)[/]"))
        }
        # a wildcard-export manifest cannot be auto-loaded by command name -
        # the exact reason a "loaded" module can look completely absent (gh#2)
        if ($Manifest.Exports -eq 'wildcard' -and -not $Entry.Loaded) {
            $facts.Add(@('exports', "[$script:PSMM_ColWarn]declared as '*'[/] [$script:PSMM_ColDim]$mid its commands cannot autoload $mid import it before using them[/]"))
        }
        if ($Manifest.ProjectUri) {
            $facts.Add(@('project', (Get-PSMMLinkMarkup -Url "$($Manifest.ProjectUri)")))
        }
    }
    if ($Auth -and $Auth.Supported) {
        $authTxt = if ($Auth.Connected) { "[$script:PSMM_ColOk]connected[/] $(ConvertTo-PSMMSafe $Auth.Account)$(if ($Auth.Detail) { " [$script:PSMM_ColDim]$mid $(ConvertTo-PSMMSafe $Auth.Detail)[/]" })" }
                  else { "[$script:PSMM_ColDim]not connected[/]" }
        $facts.Add(@('connection', $authTxt))
    }
    if ($Manifest -and $Manifest.CloudOnly) {
        $facts.Add(@('cloud', "[$script:PSMM_ColWarn]$($Manifest.CloudOnly) file(s) are OneDrive cloud-only[/] [$script:PSMM_ColDim]$mid downloaded before the next load[/]"))
    }
    if ($Entry.Issues.Count) {
        $facts.Add(@('issues', "[$script:PSMM_ColErr]$([char]0x26A0) $(ConvertTo-PSMMSafe ($Entry.Issues -join '; '))[/]"))
    }
    $fg = [Spectre.Console.Grid]::new()
    $lcol = [Spectre.Console.GridColumn]::new()
    $lcol.Padding = [Spectre.Console.Padding]::new(0, 0, 3, 0)
    [void]$fg.AddColumn($lcol)
    [void]$fg.AddColumn([Spectre.Console.GridColumn]::new())
    foreach ($f in $facts) {
        [void][Spectre.Console.GridExtensions]::AddRow($fg, [string[]]@("[$script:PSMM_ColDim]$($f[0])[/]", $f[1]))
    }
    $panel = [Spectre.Console.Panel]::new($fg)
    $panel.Border = [Spectre.Console.BoxBorder]::Rounded
    $panel.BorderStyle = Get-PSMMBorderStyle

    # --- actions grouped by what they touch (§2e); same verbs, same keys --
    $upkeepPairs = @()
    if ($Entry.Installed) {
        # the target version keeps its prerelease label here too, or the hint
        # would offer "update to 0.1.0" when it means 0.1.0-beta9 (gh#6)
        $upkeepPairs += if ($Entry.UpdateAvailable -and $Entry.LatestVersion) {
            "u=update to $(Get-PSMMVersionText -Version $Entry.LatestVersion -Prerelease $Entry.LatestPrerelease)"
        } else { 'u=update' }
        if (@($Entry.InstalledVersions).Count -gt 1) { $upkeepPairs += "x=clean $(@($Entry.InstalledVersions).Count - 1) old version(s)" }
    } else { $upkeepPairs += 'i=install' }
    $entryPairs = if ($isUnmanaged) { @('a=add to config') }
                  elseif ($Entry.Writable -and $Entry.Source -ne '<profile inline>') {
                      @('e=edit', 'v=pin version',
                        $(if ($Entry.AllowPrerelease) { 'w=stable only' } else { 'w=allow prereleases' }),
                        'd=delete', 'm=move to file')
                  } else { @() }
    $connPairs = @()
    if ($Auth -and $Auth.Supported) {
        $connPairs += 's=re-check'
        if ($Auth.Connected) { $connPairs += 'o=disconnect' }
    } elseif (Get-PSMMAuthProvider -ModuleName $Entry.Name) {
        $connPairs += 's=check connection'
    }
    # files on disk, as opposed to the config-file entry (gh#4)
    $diskPairs = if ($Entry.Installed) { @('p=move to another location') } else { @() }
    $groups = @(
        , @('session', @('^l=load', '^u=unload', 'b=browse commands'))
        , @('upkeep', $upkeepPairs)
        , @('entry', $entryPairs)
        , @('files', $diskPairs)
        , @('connection', $connPairs)
    )
    $ag = [Spectre.Console.Grid]::new()
    $gcol = [Spectre.Console.GridColumn]::new()
    $gcol.Padding = [Spectre.Console.Padding]::new(0, 0, 3, 0)
    [void]$ag.AddColumn($gcol)
    [void]$ag.AddColumn([Spectre.Console.GridColumn]::new())
    foreach ($g in $groups) {
        if (-not @($g[1]).Count) { continue }
        [void][Spectre.Console.GridExtensions]::AddRow($ag, [string[]]@(
                "[$script:PSMM_ColDim]$($g[0])[/]", (Get-PSMMHint -Pairs @($g[1]) -NoLegend)))
    }

    $items = [System.Collections.Generic.List[Spectre.Console.Rendering.IRenderable]]::new()
    $items.Add([Spectre.Console.Markup]::new((Get-PSMMHeaderBar -Breadcrumb @('home', "$($Entry.Name)") -RightMarkup (Get-PSMMStateMarkup -Entry $Entry))))
    $items.Add($panel)
    $items.Add($ag)
    $items.Add([Spectre.Console.Markup]::new((Get-PSMMHint -Pairs @('left/right=back / commands') -NoLegend)))
    $items.Add([Spectre.Console.Markup]::new((Get-PSMMPersistentHint -Pairs @("g=goto$([char]0x2026)", '?=help', 'esc=back', '^q=quit'))))
    if ($StatusMarkup) { $items.Add([Spectre.Console.Markup]::new($StatusMarkup)) }
    [Spectre.Console.Rows]::new($items)
}

function script:Show-PSMMModuleMenu {
    param([Parameter(Mandatory)] $Entry)
    $ui = $script:PSMM_UI
    $isUnmanaged = [bool]$Entry.PSObject.Properties['Unmanaged']
    $auth = $null
    # cheap providers get an automatic status check when the module is loaded;
    # slow ones (network round-trip) only on the explicit I key
    $provider = Get-PSMMAuthProvider -ModuleName $Entry.Name
    if ($provider -and -not $provider.Slow) { $auth = Get-PSMMConnectionStatus -ModuleName $Entry.Name }

    # manifest facts (author, project URL, type, command count, cloud-only
    # placeholders), resolved once - never in the render path. The cloud-only
    # scan walks the module folder, so say what is happening rather than
    # pausing silently on a big module in OneDrive.
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColMute]reading $(ConvertTo-PSMMSafe $Entry.Name) details...[/]"
    $manifest = Resolve-PSMMModuleFacts -Entry $Entry

    $status = ''
    while ($true) {
        if ($ui.HardQuit -or $ui.Goto) { return }
        Clear-PSMMScreen
        Write-PSMMRenderable (Build-PSMMModuleMenuView -Entry $Entry -Auth $auth -StatusMarkup $status -Manifest $manifest)
        $status = ''

        $k = [Console]::ReadKey($true)
        if (Test-PSMMHardQuitKey $k) { $ui.HardQuit = $true; return }
        if ($k.KeyChar -eq 'g') {
            $dest = Read-PSMMGotoKey -BaseRenderable (Build-PSMMModuleMenuView -Entry $Entry -Auth $auth -Manifest $manifest)
            if ($dest) { $ui.Goto = $dest; return }
            continue
        }
        if (Test-PSMMHomeKey $k) { $ui.Goto = 'home'; return }
        $ctrl = ($k.Modifiers -band [ConsoleModifiers]::Control) -ne 0
        switch ($k.Key) {
            ([ConsoleKey]::L) {
                Write-PSMMLine "[$script:PSMM_ColAccent]loading $(ConvertTo-PSMMSafe $Entry.Name)...[/]"
                if (-not (Confirm-PSMMCloudHydration -ModuleName $Entry.Name)) {
                    $status = "[$script:PSMM_ColMute]load cancelled (cloud-only files not downloaded)[/]"
                } else {
                    try {
                        Import-PSMMModuleTimed -Entry $Entry
                        $status = "[$script:PSMM_ColOk]loaded ($($Entry.ImportMs) ms)[/]"
                    } catch { $status = "[$script:PSMM_ColErr]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" }
                    Update-PSMMLoaded -Entries $ui.Entries
                }
            }
            ([ConsoleKey]::U) {
                if ($ctrl -and (Test-PSMMOwnModule -Name $Entry.Name)) {
                    # unloading psmm or its UI engine kills the screen you are
                    # standing on (gh#16)
                    $status = "[$script:PSMM_ColWarn]$(ConvertTo-PSMMSafe $Entry.Name) is psmm's own - unloading it would take the manager down with it[/]"
                } elseif ($ctrl) {
                    Write-PSMMLine "[$script:PSMM_ColAccent]unloading $(ConvertTo-PSMMSafe $Entry.Name)...[/]"
                    try { Remove-Module -Name $Entry.Name -Force -ErrorAction Stop; $status = "[$script:PSMM_ColOk]unloaded[/]" }
                    catch { $status = "[$script:PSMM_ColErr]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" }
                    Update-PSMMLoaded -Entries $ui.Entries
                } elseif (-not $Entry.Installed) {
                    $status = "[$script:PSMM_ColWarn]not installed - i installs it first[/]"
                } else {
                    Clear-PSMMScreen
                    Write-PSMMLine "[$script:PSMM_ColAccent]updating $(ConvertTo-PSMMSafe $Entry.Name)... (this can take a while)[/]"
                    try {
                        Install-PSMMModule -Name $Entry.Name -Update -Version $Entry.Version -Prerelease:([bool]$Entry.AllowPrerelease)
                        $status = "[$script:PSMM_ColOk]update done[/]"
                    } catch { $status = "[$script:PSMM_ColErr]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" }
                    Update-PSMMAvailable -Entries $ui.Entries -Name $Entry.Name
                }
            }
            ([ConsoleKey]::I) {
                if ($ctrl) { continue }
                if ($Entry.Installed) {
                    $status = "[$script:PSMM_ColWarn]already installed - u updates it[/]"
                } else {
                    Clear-PSMMScreen
                    Write-PSMMLine "[$script:PSMM_ColAccent]installing $(ConvertTo-PSMMSafe $Entry.Name)... (this can take a while)[/]"
                    try {
                        Install-PSMMModule -Name $Entry.Name -Version $Entry.Version -Prerelease:([bool]$Entry.AllowPrerelease)
                        $status = "[$script:PSMM_ColOk]install done[/]"
                    } catch { $status = "[$script:PSMM_ColErr]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" }
                    Update-PSMMAvailable -Entries $ui.Entries -Name $Entry.Name
                }
            }
            ([ConsoleKey]::B) { Show-PSMMCommands -Entry $Entry }
            ([ConsoleKey]::X) {
                if ($Entry.Installed -and @($Entry.InstalledVersions).Count -gt 1) {
                    $status = Invoke-PSMMVersionCleanup -Entry $Entry
                    Update-PSMMAvailable -Entries $ui.Entries -Name $Entry.Name
                }
            }
            ([ConsoleKey]::S) {
                if ($provider) {
                    Write-PSMMLine "[$script:PSMM_ColAccent]checking connection status$(if ($provider.Slow) { ' (network - may take a moment)' })...[/]"
                    $auth = Get-PSMMConnectionStatus -ModuleName $Entry.Name
                }
            }
            ([ConsoleKey]::O) {
                if ($auth -and $auth.Connected) {
                    Write-PSMMLine "[$script:PSMM_ColAccent]disconnecting $(ConvertTo-PSMMSafe $Entry.Name)...[/]"
                    try {
                        Disconnect-PSMMModule -ModuleName $Entry.Name
                        $status = "[$script:PSMM_ColOk]disconnected[/]"
                        $auth = Get-PSMMConnectionStatus -ModuleName $Entry.Name
                    } catch { $status = "[$script:PSMM_ColErr]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" }
                }
            }
            ([ConsoleKey]::A) {
                if ($isUnmanaged) { if (Add-PSMMUnmanagedEntry -Entry $Entry) { return } }
            }
            ([ConsoleKey]::E) { if (-not $isUnmanaged -and $Entry.Writable) { if (Edit-PSMMEntry -Entry $Entry) { return }; $status = $script:PSMM_UI.Status } }
            ([ConsoleKey]::V) { if (-not $isUnmanaged -and $Entry.Writable) { if (Set-PSMMEntryPin -Entry $Entry) { return } ; $status = $script:PSMM_UI.Status } }
            ([ConsoleKey]::W) { if (-not $ctrl -and -not $isUnmanaged -and $Entry.Writable) { if (Set-PSMMEntryPrerelease -Entry $Entry) { return } } }
            ([ConsoleKey]::D) { if (-not $isUnmanaged -and $Entry.Writable) { if (Remove-PSMMEntryUI -Entry $Entry) { return } } }
            ([ConsoleKey]::M) { if (-not $isUnmanaged -and $Entry.Writable -and $Entry.Source -ne '<profile inline>') { if (Move-PSMMEntryUI -Entry $Entry) { return } } }
            ([ConsoleKey]::P) {
                # a stray ctrl+p must never open a filesystem-move dialog
                if (-not $ctrl -and $Entry.Installed) {
                    $status = Move-PSMMModuleLocationUI -Entry $Entry
                    Update-PSMMAvailable -Entries $ui.Entries -Name $Entry.Name
                    $manifest = Resolve-PSMMModuleFacts -Entry $Entry
                }
            }
            ([ConsoleKey]::Escape) { return }
            ([ConsoleKey]::LeftArrow) { return }   # "move out" (#24)
            ([ConsoleKey]::RightArrow) { Show-PSMMCommands -Entry $Entry }   # "move into" commands (#24)
            default { if ($k.KeyChar -eq '?') { Show-PSMMHelpScreen -Topic 'module' } }
        }
    }
}

# Prune all but the newest installed version of a module (#30-lite, research
# "Clean Up"). Respects scope: AllUsers copies need elevation.
function script:Invoke-PSMMVersionCleanup {
    param([Parameter(Mandatory)] $Entry)
    $ui = $script:PSMM_UI
    $obsolete = @($Entry.InstalledVersions | Sort-Object { [version]"$($_.Version)" } -Descending | Select-Object -Skip 1)
    if (-not $obsolete.Count) { return "[$script:PSMM_ColMute]nothing to clean[/]" }
    $blocked = @($obsolete | Where-Object { $_.Scope -eq 'AllUsers' -and -not $ui.Elevated })
    $doable  = @($obsolete | Where-Object { $_.Scope -ne 'AllUsers' -or $ui.Elevated })
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]Clean up old versions of $(ConvertTo-PSMMSafe $Entry.Name)[/]"
    Write-PSMMLine "keeping [$script:PSMM_ColOk]v$($Entry.InstalledVersion)[/], removing: $(($doable | ForEach-Object { "v$($_.Version)" }) -join ', ')"
    if ($blocked.Count) { Write-PSMMLine "[$script:PSMM_ColWarn]skipping $(@($blocked).Count) AllUsers version(s) - session is not elevated[/]" }
    if (-not $doable.Count) { $null = Wait-PSMMKey; return "[$script:PSMM_ColWarn]nothing removable without elevation[/]" }
    if (-not (Read-SpectreConfirm -Message "Remove $($doable.Count) old version(s)?" -DefaultAnswer 'n')) { return "[$script:PSMM_ColMute]cleanup cancelled[/]" }
    $ok = 0; $failed = 0
    foreach ($v in $doable) {
        Write-PSMMLine "[$script:PSMM_ColAccent]removing v$($v.Version)...[/]"
        try { Uninstall-PSMMModuleVersion -Name $Entry.Name -Version "$($v.Version)"; $ok++ }
        catch { $failed++; Write-PSMMLine "[$script:PSMM_ColErr] v$($v.Version): $(ConvertTo-PSMMSafe $_.Exception.Message)[/]" }
    }
    if ($failed) { "[$script:PSMM_ColWarn]removed $ok, $failed failed[/]" } else { "[$script:PSMM_ColOk]removed $ok old version(s)[/]" }
}

# Pin (or unpin) an entry's version and save (#research: version pinning).
# Returns $true when the config changed (caller reloads).
#
# gh#5: the old prompt was a bare text box - you had to know a version and type
# it. psmm knows which versions exist (on disk, instantly; in the gallery, over
# the network), so it offers them, with the CURRENT pin preselected. Free text
# stays available because a NuGet range can never come from a list.
function script:Set-PSMMEntryPin {
    param([Parameter(Mandatory)] $Entry)
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]Pin $(ConvertTo-PSMMSafe $Entry.Name) to a version[/]"
    $current = "$($Entry.Version)"
    if ($current) { Write-PSMMLine "[$script:PSMM_ColMute]currently pinned to[/] [$script:PSMM_ColAccent]$(ConvertTo-PSMMSafe $current)[/]" }
    else { Write-PSMMLine "[$script:PSMM_ColMute]not pinned - psmm installs whatever the gallery calls latest[/]" }
    Write-PSMMLine "[$script:PSMM_ColMute]looking up available versions (gallery - may take a moment)...[/]"
    $available = @(Get-PSMMAvailableVersion -Name $Entry.Name -Prerelease:([bool]$Entry.AllowPrerelease))

    $typeLabel = 'type an exact version or a NuGet range...'
    $clearLabel = 'remove the pin (always install latest)'
    $choices = [System.Collections.Generic.List[string]]::new()
    $byLabel = @{}
    foreach ($v in $available) {
        $where = @()
        if ($v.OnDisk) { $where += 'on disk' }
        if ($v.InGallery) { $where += 'gallery' }
        $label = "$($v.Display) ($($where -join ', '))"
        if ($byLabel.ContainsKey($label)) { continue }
        $byLabel[$label] = $v.Display
        $choices.Add($label)
    }
    $choices.Add($typeLabel)
    if ($current) { $choices.Add($clearLabel) }
    # preselect what the user most likely wants: the existing pin, else the
    # installed version - Read-SpectreSelection has no "default" parameter, so
    # the preselected item is moved to the top of the list
    $preferred = if ($current) { $current } else { Get-PSMMVersionText -Version $Entry.InstalledVersion -Prerelease $Entry.InstalledPrerelease }
    $ordered = @($choices)
    if ($preferred) {
        $hit = @($choices | Where-Object { $byLabel[$_] -eq $preferred }) | Select-Object -First 1
        if ($hit) { $ordered = @($hit) + @($choices | Where-Object { $_ -ne $hit }) }
    }

    $picked = Read-SpectreSelection -Message 'Pin to' -Choices $ordered -Color $script:PSMM_ColAccent
    if (-not $picked) { $script:PSMM_UI.Status = "[$script:PSMM_ColMute]pin cancelled - nothing saved[/]"; return $false }
    $v = switch ($picked) {
        $clearLabel { '' }
        $typeLabel {
            Write-PSMMLine "[$script:PSMM_ColMute]exact '1.2.3' or NuGet range '[[1.0,2.0)'; empty removes the pin[/]"
            $typed = Read-PSMMText -Message 'Version' -DefaultAnswer $current -AllowEmpty
            if ($null -eq $typed) { $script:PSMM_UI.Status = "[$script:PSMM_ColMute]pin cancelled - nothing saved[/]"; return $false }
            "$typed".Trim()
        }
        default { "$($byLabel[$picked])" }
    }
    if ($v -eq $current) { $script:PSMM_UI.Status = "[$script:PSMM_ColMute]pin unchanged[/]"; return $false }
    $probe = Resolve-PSMMEntry -Raw ([pscustomobject]@{ Name = $Entry.Name; Version = $v }) -Source $Entry.Source -Writable $true
    if ($v -and -not $probe.Version) {
        Write-PSMMLine "[$script:PSMM_ColErr]'$(ConvertTo-PSMMSafe $v)' is not a valid version or range - nothing saved[/]"
        $null = Wait-PSMMKey
        return $false
    }
    $Entry.Version = $probe.Version
    $Entry.PinnedExact = $probe.PinnedExact
    Save-PSMMFile -Path $Entry.Source -Entries (Get-PSMMAllEntries)
    $script:PSMM_UI.Dirty = $true
    $true
}

# Toggle the entry's prerelease policy and save (gh#6).
function script:Set-PSMMEntryPrerelease {
    param([Parameter(Mandatory)] $Entry)
    if (-not $Entry.Writable) { return $false }
    $Entry.AllowPrerelease = -not $Entry.AllowPrerelease
    Save-PSMMFile -Path $Entry.Source -Entries (Get-PSMMAllEntries)
    $script:PSMM_UI.Status = if ($Entry.AllowPrerelease) {
        "[$script:PSMM_ColOk]prereleases allowed for $(ConvertTo-PSMMSafe $Entry.Name) - install, update and pin now see them[/]"
    } else {
        "[$script:PSMM_ColOk]$(ConvertTo-PSMMSafe $Entry.Name) tracks stable releases only[/]"
    }
    $script:PSMM_UI.Dirty = $true
    $true
}

# Move a module's files to another module location (gh#4). Returns a status
# markup line. Every version of the module moves together: the folder that
# moves is <root>\<Name>, never one version out of a multi-version tree.
function script:Move-PSMMModuleLocationUI {
    param([Parameter(Mandatory)] $Entry)
    $vers = @($Entry.InstalledVersions)
    if (-not $vers.Count) { return "[$script:PSMM_ColWarn]not installed - nothing to move[/]" }
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]Move $(ConvertTo-PSMMSafe $Entry.Name) to another module location[/]"
    $tree = Get-PSMMModuleTree -ModuleBase "$($vers[0].Path)" -Name $Entry.Name
    Write-PSMMLine "[$script:PSMM_ColMute]from[/] [$script:PSMM_ColDim]$(ConvertTo-PSMMSafe $tree.Tree)[/]"
    Write-PSMMLine "[$script:PSMM_ColMute]$(@($vers).Count) version(s), $(Format-PSMMSize (Get-PSMMFolderSize -Path $tree.Tree))[/]"

    # a loaded module holds its files open - binary modules in particular
    if ($Entry.Loaded) {
        Write-PSMMProse -Text "$($Entry.Name) is imported in this session, so its files are in use and the move will fail. Unload it first (ctrl+u), then try again." -Colour $script:PSMM_ColWarn
        $null = Wait-PSMMKey
        return "[$script:PSMM_ColWarn]move cancelled - unload the module first[/]"
    }

    $targets = @(Get-PSMMModulePathInfo |
            Where-Object { $_.Exists -and (Test-PSMMDirectoryWritable -Path $_.Path) -and
                           $_.Path.TrimEnd('\', '/') -ne "$($tree.Root)".TrimEnd('\', '/') } |
            Select-Object -ExpandProperty Path)
    $other = 'somewhere else (type a path)...'
    if (-not $targets.Count) {
        Write-PSMMProse -Text 'No other writable folder on the module search path. Add one first (g p, then n), or type a path below.' -Colour $script:PSMM_ColWarn
    }
    $pick = Read-SpectreSelection -Message 'Move to' -Choices (@($targets) + @($other)) -Color $script:PSMM_ColAccent
    if (-not $pick) { return "[$script:PSMM_ColMute]move cancelled[/]" }
    $target = if ($pick -eq $other) { Read-PSMMText -Message 'Target folder' } else { $pick }
    if ([string]::IsNullOrWhiteSpace($target)) { return "[$script:PSMM_ColMute]move cancelled[/]" }
    if (-not (Test-Path -LiteralPath $target -PathType Container)) {
        if (-not (Read-SpectreConfirm -Message "Create $(ConvertTo-PSMMSafe $target) now?" -DefaultAnswer 'y')) { return "[$script:PSMM_ColMute]move cancelled[/]" }
        try { New-Item -ItemType Directory -Force -Path $target | Out-Null }
        catch { return "[$script:PSMM_ColErr]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" }
    }
    if (-not (Test-PSMMDirectoryWritable -Path $target)) { $null = Wait-PSMMKey; return "[$script:PSMM_ColErr]target folder is not writable[/]" }
    if (-not (Read-SpectreConfirm -Message "Move $(ConvertTo-PSMMSafe $Entry.Name) to $(ConvertTo-PSMMSafe $target)?" -DefaultAnswer 'n')) { return "[$script:PSMM_ColMute]move cancelled[/]" }

    $results = @(Move-PSMMModuleTree -Name $Entry.Name -InstalledVersions $vers -TargetRoot $target)
    foreach ($r in $results) {
        if ($r.Moved) { Write-PSMMLine "[$script:PSMM_ColOk]moved[/] [$script:PSMM_ColDim]$(ConvertTo-PSMMSafe $r.To)[/]" }
        else { Write-PSMMLine "[$script:PSMM_ColErr]$(ConvertTo-PSMMSafe $r.From): $(ConvertTo-PSMMSafe $r.Reason)[/]" }
    }
    $moved = @($results | Where-Object Moved).Count
    if ($moved -and -not (Test-PSMMModulePathContains -Path $target)) {
        Write-PSMMProse -Text "$target is not on the module search path, so PowerShell will not find the module there. Add it on the paths screen (g p, then n)." -Colour $script:PSMM_ColWarn
    }
    $script:PSMM_UI.Dirty = $true
    $null = Wait-PSMMKey
    if ($moved -eq @($results).Count) { "[$script:PSMM_ColOk]moved $(ConvertTo-PSMMSafe $Entry.Name) to $(ConvertTo-PSMMSafe $target)[/]" }
    elseif ($moved) { "[$script:PSMM_ColWarn]moved $moved of $(@($results).Count) folder(s)[/]" }
    else { "[$script:PSMM_ColErr]nothing moved[/]" }
}

# Writable targets for adding an entry; when none exist, offer to create the
# main config on the spot (2026-07-05 live-run feedback) instead of a dead
# end. Save-PSMMFile creates the file itself - only the directory must exist.
function script:Get-PSMMAddTargets {
    $meta = Get-PSMMFileMeta
    $targets = @($meta.Values | Where-Object { $_.Writable -and $_.Kind -ne 'inline' } | Select-Object -ExpandProperty Path)
    if ($targets.Count) { return $targets }
    $main = Get-PSMMMainConfigPath
    Write-PSMMLine "[$script:PSMM_ColWarn]No writable config file yet.[/]"
    if (-not (Read-SpectreConfirm -Message "Create $(ConvertTo-PSMMSafe $main) now?" -DefaultAnswer 'y')) { return @() }
    $dir = Split-Path -Parent $main
    if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
    @($main)
}

# Add an unmanaged module to a writable config file (#27).
function script:Add-PSMMUnmanagedEntry {
    param([Parameter(Mandatory)] $Entry)
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]Add $(ConvertTo-PSMMSafe $Entry.Name) to a config file[/]"
    $targets = @(Get-PSMMAddTargets)
    if (-not $targets.Count) { return $false }
    $target = if ($targets.Count -eq 1) { $targets[0] } else { Read-SpectreSelection -Message 'Add to which file?' -Choices $targets -Color $script:PSMM_ColAccent }
    if (-not $target) { return $false }
    $install = Read-SpectreSelection -Message 'Install policy' -Choices 'IfMissing', 'CheckOnly', 'Latest' -Color $script:PSMM_ColAccent
    $mode    = Read-SpectreSelection -Message 'Mode' -Choices 'Load', 'InstallOnly', 'Ignore' -Color $script:PSMM_ColAccent
    $new = Resolve-PSMMEntry -Raw ([pscustomobject]@{
        Name = $Entry.Name; Description = $Entry.Description; Install = $install; Mode = $mode
    }) -Source $target -Writable $true
    $new | Add-Member -NotePropertyName FromMain -NotePropertyValue ($target -eq (Get-PSMMMainConfigPath)) -Force
    $new | Add-Member -NotePropertyName FileEnabled -NotePropertyValue $true -Force
    Add-PSMMAllEntry -Entry $new
    Save-PSMMFile -Path $target -Entries (Get-PSMMAllEntries)
    Write-PSMMLine "[$script:PSMM_ColOk]added to $(ConvertTo-PSMMSafe (Split-Path $target -Leaf)) - it is now managed[/]"
    $script:PSMM_UI.Dirty = $true
    $null = Wait-PSMMKey
    $true
}

# Edit an entry's fields and save. Esc on any text prompt aborts WITHOUT
# touching the entry (live-run feedback); nothing is assigned until every
# answer is in. Returns $true when the entry was saved.
function script:Edit-PSMMEntry {
    param([Parameter(Mandatory)] $Entry)
    if (-not $Entry.Writable) { return $false }
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]Edit $(ConvertTo-PSMMSafe $Entry.Name)[/]"
    $cancelled = "[$script:PSMM_ColMute]edit cancelled - nothing saved[/]"
    $name = Read-PSMMText -Message 'Name' -DefaultAnswer $Entry.Name
    if ($null -eq $name) { $script:PSMM_UI.Status = $cancelled; return $false }
    $friendly = Read-PSMMText -Message 'Friendly name' -DefaultAnswer ($Entry.FriendlyName ?? '') -AllowEmpty
    if ($null -eq $friendly) { $script:PSMM_UI.Status = $cancelled; return $false }
    $desc = Read-PSMMText -Message 'Description' -DefaultAnswer ($Entry.Description ?? '') -AllowEmpty
    if ($null -eq $desc) { $script:PSMM_UI.Status = $cancelled; return $false }
    $install = Read-SpectreSelection -Message 'Install policy' -Choices (@($Entry.Install) + (@('IfMissing', 'CheckOnly', 'Latest') | Where-Object { $_ -ne $Entry.Install })) -Color $script:PSMM_ColAccent
    $mode    = Read-SpectreSelection -Message 'Mode'           -Choices (@($Entry.Mode) + (@('Load', 'InstallOnly', 'Ignore') | Where-Object { $_ -ne $Entry.Mode })) -Color $script:PSMM_ColAccent
    $preOpts = if ($Entry.AllowPrerelease) { @('allow prereleases', 'stable only') } else { @('stable only', 'allow prereleases') }
    $pre     = Read-SpectreSelection -Message 'Versions' -Choices $preOpts -Color $script:PSMM_ColAccent
    $Entry.Name            = $name
    $Entry.FriendlyName    = $friendly
    $Entry.Description     = $desc
    $Entry.Install         = $install
    $Entry.Mode            = $mode
    $Entry.AllowPrerelease = ($pre -eq 'allow prereleases')
    Save-PSMMFile -Path $Entry.Source -Entries (Get-PSMMAllEntries)
    $script:PSMM_UI.Dirty = $true
    $true
}

# Create a brand-new entry (grid key 'a').
function script:New-PSMMEntry {
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]New entry[/]"
    $targets = @(Get-PSMMAddTargets)
    if (-not $targets.Count) { $script:PSMM_UI.Status = "[$script:PSMM_ColMute]add cancelled - no config file[/]"; return }
    $target = if ($targets.Count -eq 1) { $targets[0] } else { Read-SpectreSelection -Message 'Add to which file?' -Choices $targets -Color $script:PSMM_ColAccent }
    $cancelled = "[$script:PSMM_ColMute]add cancelled - nothing saved[/]"
    $name = Read-PSMMText -Message 'Module name'
    if ($null -eq $name -or [string]::IsNullOrWhiteSpace($name)) { $script:PSMM_UI.Status = $cancelled; return }
    $friendly = Read-PSMMText -Message 'Friendly name' -AllowEmpty
    if ($null -eq $friendly) { $script:PSMM_UI.Status = $cancelled; return }
    $desc = Read-PSMMText -Message 'Description' -AllowEmpty
    if ($null -eq $desc) { $script:PSMM_UI.Status = $cancelled; return }
    $install  = Read-SpectreSelection -Message 'Install policy' -Choices 'IfMissing', 'CheckOnly', 'Latest' -Color $script:PSMM_ColAccent
    $mode     = Read-SpectreSelection -Message 'Mode' -Choices 'Load', 'InstallOnly', 'Ignore' -Color $script:PSMM_ColAccent
    $pre      = Read-SpectreSelection -Message 'Versions' -Choices 'stable only', 'allow prereleases' -Color $script:PSMM_ColAccent
    $new = Resolve-PSMMEntry -Raw ([pscustomobject]@{
        Name = $name; FriendlyName = $friendly; Description = $desc; Install = $install; Mode = $mode
        Prerelease = ($pre -eq 'allow prereleases')
    }) -Source $target -Writable $true
    $new | Add-Member -NotePropertyName FromMain -NotePropertyValue ($target -eq (Get-PSMMMainConfigPath)) -Force
    $new | Add-Member -NotePropertyName FileEnabled -NotePropertyValue $true -Force
    Add-PSMMAllEntry -Entry $new
    Save-PSMMFile -Path $target -Entries (Get-PSMMAllEntries)
    $script:PSMM_UI.Dirty = $true
}

# Delete an entry (with confirm). Returns $true when deleted.
function script:Remove-PSMMEntryUI {
    param([Parameter(Mandatory)] $Entry)
    if (-not $Entry.Writable) { return $false }
    if (-not (Read-SpectreConfirm -Message "Delete '$(ConvertTo-PSMMSafe $Entry.Name)' from $(ConvertTo-PSMMSafe (Split-Path $Entry.Source -Leaf))?" -DefaultAnswer 'n')) { return $false }
    Set-PSMMAllEntries -Entries @(Get-PSMMAllEntries | Where-Object { -not ($_.Source -eq $Entry.Source -and $_.Name -eq $Entry.Name) })
    Save-PSMMFile -Path $Entry.Source -Entries (Get-PSMMAllEntries)
    $script:PSMM_UI.Dirty = $true
    return $true
}

# Move one entry to another writable config file.
function script:Move-PSMMEntryUI {
    param([Parameter(Mandatory)] $Entry)
    if (-not $Entry.Writable -or $Entry.Source -eq '<profile inline>') { return $false }
    $meta = Get-PSMMFileMeta
    $targets = @($meta.Values | Where-Object { $_.Writable -and $_.Kind -ne 'inline' -and $_.Path -ne $Entry.Source } | Select-Object -ExpandProperty Path)
    if (-not $targets.Count) {
        Write-PSMMLine "[$script:PSMM_ColWarn]No other writable config file to move to (create one via f -> n).[/]"
        $null = Wait-PSMMKey
        return $false
    }
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]Move $(ConvertTo-PSMMSafe $Entry.Name)[/]"
    $target = if ($targets.Count -eq 1) { $targets[0] } else { Read-SpectreSelection -Message 'Move to which file?' -Choices $targets -Color $script:PSMM_ColAccent }
    if (-not $target) { return $false }
    $oldSource = $Entry.Source
    $Entry.Source = $target
    Save-PSMMFile -Path $oldSource -Entries (Get-PSMMAllEntries)   # removes it there
    Save-PSMMFile -Path $target -Entries (Get-PSMMAllEntries)      # adds it here
    Write-PSMMLine "[$script:PSMM_ColOk]Moved to $(ConvertTo-PSMMSafe (Split-Path $target -Leaf)).[/]"
    $script:PSMM_UI.Dirty = $true
    $null = Wait-PSMMKey
    return $true
}