src/UI/75-Paths.ps1

# 75-Paths.ps1 — module locations screen: every PSModulePath entry with
# OneDrive/cloud-placeholder diagnostics, download (hydrate) and pin actions,
# and management of the primary (CurrentUser) module location via the
# documented powershell.config.json PSModulePath override.

function script:Build-PSMMPathsView {
    param(
        [Parameter(Mandatory)] $State,
        [Parameter(Mandatory)] $Infos,
        [string]$StatusMarkup
    )
    if (Test-PSMMWinTooSmall) { return (Get-PSMMTooSmallView) }
    $n = $Infos.Count
    $win = Get-PSMMWinSize
    $vp = Get-PSMMViewport -State $State -Count $n -Rows ($win.Height - 14)
    $T = [Spectre.Console.Table]::new()
    $T.Border = [Spectre.Console.TableBorder]::Rounded
    foreach ($h in ' ', '#', 'Path', 'Notes') { [void][Spectre.Console.TableExtensions]::AddColumn($T, $h) }
    for ($i = $vp.First; $i -le $vp.Last; $i++) {
        $p = $Infos[$i]
        $nm = ConvertTo-PSMMSafe (Get-PSMMTrunc $p.Path ([Math]::Max(20, $win.Width - 45)))
        if ($i -eq $State.Cursor) { $nm = "[$script:PSMM_ColAccent]$nm[/]" }
        $notes = @()
        if ($p.First) { $notes += '[deepskyblue1]first[/]' }
        if ($p.UserDefault) { $notes += 'user default' }
        if ($p.OneDrive) { $notes += '[orange1]onedrive[/]' }
        if (-not $p.Exists) { $notes += '[indianred1]missing[/]' }
        [void][Spectre.Console.TableExtensions]::AddRow($T, [string[]]@(
                $(if ($i -eq $State.Cursor) { "[$script:PSMM_ColAccent]>[/]" } else { ' ' }),
                "$($p.Order + 1)", $nm, ($notes -join ' ')))
    }
    $pos = Get-PSMMPositionMarkup -State $State -Count $n -Viewport $vp
    $items = [System.Collections.Generic.List[Spectre.Console.Rendering.IRenderable]]::new()
    $items.Add([Spectre.Console.Markup]::new("[$script:PSMM_ColAccent]Module locations[/] [$script:PSMM_ColMute](`$env:PSModulePath, search order = list order)[/]$pos"))
    $items.Add($T)

    # OneDrive diagnosis: pwsh derives the FIRST (CurrentUser) entry from the
    # Documents known folder; OneDrive folder backup / KFM policy moves it.
    $first = if ($n) { $Infos[0] } else { $null }
    if ($first -and $first.OneDrive) {
        $items.Add([Spectre.Console.Markup]::new('[orange1]your primary module location is inside OneDrive[/]'))
        $items.Add([Spectre.Console.Markup]::new("[$script:PSMM_ColMute]this is PowerShell's default when OneDrive backs up the Documents folder (a common org policy," +
            ' not something you did). cloud-only files there can stall or fail module loading -' +
            ' d downloads them, k keeps the folder on this device, s moves the primary location.[/]'))
    }
    $items.Add([Spectre.Console.Markup]::new((Get-PSMMHint -Pairs @('up/dn=move', 'd=download cloud-only files', 'k=keep on device (pin)', '?=help', 'esc=back', 'g h=home', '^q=quit'))))
    $items.Add([Spectre.Console.Markup]::new((Get-PSMMHint -Pairs @('s=set primary location', 'r=remove primary override'))))
    if ($StatusMarkup) { $items.Add([Spectre.Console.Markup]::new($StatusMarkup)) }
    [Spectre.Console.Rows]::new($items)
}

function script:Show-PSMMPaths {
    $ui = $script:PSMM_UI
    $st = New-PSMMListState
    $st.Status = ''
    while ($true) {
        if ($ui.HardQuit -or $ui.GoHome) { return }
        $infos = @(Get-PSMMModulePathInfo)
        $cmd = @{ Name = $null }
        Clear-PSMMScreen
        Invoke-PSMMLive -Body {
            param($ctx)
            while ($true) {
                if ($script:PSMM_UI.HardQuit) { return }
                $ctx.UpdateTarget((Build-PSMMPathsView -State $st -Infos $infos -StatusMarkup $st.Status))
                $ctx.Refresh()
                $k = Read-PSMMKeyResize
                if ($null -eq $k) { continue }
                if (Test-PSMMHardQuitKey $k) { $script:PSMM_UI.HardQuit = $true; return }
                if (Test-PSMMHomeKey $k) { $script:PSMM_UI.GoHome = $true; return }
                $st.Status = ''
                if (Invoke-PSMMListNav -State $st -KeyInfo $k -Count $infos.Count) { continue }
                switch ($k.Key) {
                    ([ConsoleKey]::D)      { $cmd.Name = 'download'; return }
                    ([ConsoleKey]::K)      { $cmd.Name = 'pin'; return }
                    ([ConsoleKey]::S)      { $cmd.Name = 'setprimary'; return }
                    ([ConsoleKey]::R)      { $cmd.Name = 'clearprimary'; return }
                    ([ConsoleKey]::Escape) { return }
                    default { if ($k.KeyChar -eq '?') { $cmd.Name = 'help'; return } }
                }
            }
        }
        if ($ui.HardQuit -or $ui.GoHome) { return }
        $cur = if ($infos.Count) { $infos[[Math]::Min($st.Cursor, $infos.Count - 1)] } else { $null }
        switch ($cmd.Name) {
            'help' { Show-PSMMHelpScreen -Topic 'paths' }
            'download' {
                if ($cur) { $st.Status = Invoke-PSMMPathDownload -Info $cur }
            }
            'pin' {
                if ($cur) { $st.Status = Invoke-PSMMPathPin -Info $cur }
            }
            'setprimary' { $st.Status = Set-PSMMPrimaryLocationUI }
            'clearprimary' {
                Clear-PSMMScreen
                Write-PSMMLine "[$script:PSMM_ColAccent]Remove the primary-location override[/]"
                $cfg = Get-PSMMUserConfigJsonPath
                Write-PSMMLine "[grey66]removes the PSModulePath key from $(ConvertTo-PSMMSafe "$cfg") - pwsh falls back to the Documents default[/]"
                if (Read-SpectreConfirm -Message 'Remove the override?' -DefaultAnswer 'n') {
                    try {
                        $null = Set-PSMMUserModulePath -Clear
                        $st.Status = '[green3]override removed - takes effect in NEW pwsh sessions[/]'
                    } catch { $st.Status = "[indianred1]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" }
                } else { $st.Status = '[grey66]cancelled[/]' }
            }
            default { return }
        }
    }
}

# Scan one PSModulePath entry for cloud-only files and hydrate them, with a
# confirm first (downloads can take a while) and per-file progress.
function script:Invoke-PSMMPathDownload {
    param([Parameter(Mandatory)] $Info)
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]Download cloud-only files[/]"
    Write-PSMMLine "[grey66]$(ConvertTo-PSMMSafe $Info.Path)[/]"
    if (-not $Info.Exists) { $null = Wait-PSMMKey; return '[orange1]path does not exist[/]' }
    Write-PSMMLine 'scanning for cloud-only placeholder files...'
    $files = @(Get-PSMMCloudOnlyFile -Path $Info.Path)
    if (-not $files.Count) { $null = Wait-PSMMKey; return '[green3]no cloud-only files - everything is already on disk[/]' }
    $mb = [Math]::Round((($files | Measure-Object Length -Sum).Sum) / 1MB, 1)
    Write-PSMMLine "[orange1]$($files.Count) cloud-only file(s), $mb MB to download - this can take a while on a slow connection[/]"
    if (-not (Read-SpectreConfirm -Message "Download $($files.Count) file(s) now?" -DefaultAnswer 'y')) { return '[grey66]download cancelled[/]' }
    $r = Invoke-PSMMFileHydration -Files $files -OnProgress {
        param($i, $total, $f)
        Write-PSMMLine "[$script:PSMM_ColMute] ($i/$total) $(ConvertTo-PSMMSafe $f.Name)[/]"
    }
    foreach ($e in $r.Errors | Select-Object -First 5) { Write-PSMMLine "[indianred1] $(ConvertTo-PSMMSafe $e)[/]" }
    $null = Wait-PSMMKey
    if ($r.Failed) { "[orange1]downloaded $($r.Ok), $($r.Failed) failed[/]" } else { "[green3]downloaded $($r.Ok) file(s)[/]" }
}

# Pin one PSModulePath entry so OneDrive keeps it permanently on this device.
function script:Invoke-PSMMPathPin {
    param([Parameter(Mandatory)] $Info)
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]Keep on this device (pin)[/]"
    Write-PSMMLine "[grey66]$(ConvertTo-PSMMSafe $Info.Path)[/]"
    if (-not $Info.OneDrive) { $null = Wait-PSMMKey; return '[orange1]not a OneDrive path - nothing to pin[/]' }
    if (-not $Info.Exists) { $null = Wait-PSMMKey; return '[orange1]path does not exist[/]' }
    Write-PSMMLine 'marks every file "always keep on this device"; OneDrive downloads them in the background.'
    if (-not (Read-SpectreConfirm -Message 'Pin the whole folder?' -DefaultAnswer 'y')) { return '[grey66]pin cancelled[/]' }
    try {
        Invoke-PSMMPinPath -Path $Info.Path
        '[green3]pinned - OneDrive is downloading the files in the background[/]'
    } catch { "[indianred1]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" }
}

# Foreground pre-load check: when a module's files are OneDrive cloud-only
# placeholders, warn, ask, and download them with per-file progress before
# the import touches them (a failed/stalled recall mid-import is much worse).
# Returns $false when the user cancelled.
function script:Confirm-PSMMCloudHydration {
    param([Parameter(Mandatory)][string]$ModuleName)
    $files = @(Get-PSMMModuleCloudOnlyFile -Name $ModuleName)
    if (-not $files.Count) { return $true }
    $mb = [Math]::Round((($files | Measure-Object Length -Sum).Sum) / 1MB, 1)
    Write-PSMMLine "[orange1]$($files.Count) file(s) of $(ConvertTo-PSMMSafe $ModuleName) are OneDrive cloud-only ($mb MB)[/]"
    Write-PSMMLine '[grey66]they must be downloaded before loading; this can take a while on a slow connection[/]'
    if (-not (Read-SpectreConfirm -Message 'Download now and continue?' -DefaultAnswer 'y')) { return $false }
    $r = Invoke-PSMMFileHydration -Files $files -OnProgress {
        param($i, $total, $f)
        Write-PSMMLine "[$script:PSMM_ColMute] downloading ($i/$total) $(ConvertTo-PSMMSafe $f.Name)[/]"
    }
    if ($r.Failed) { Write-PSMMLine "[orange1]$($r.Failed) file(s) failed to download - the load may still fail[/]" }
    $true
}

# Prompt for and write the CurrentUser module-path override.
function script:Set-PSMMPrimaryLocationUI {
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]Set the primary (CurrentUser) module location[/]"
    $cfg = Get-PSMMUserConfigJsonPath
    Write-PSMMLine "[grey66]writes the documented PSModulePath override to $(ConvertTo-PSMMSafe "$cfg")[/]"
    Write-PSMMLine '[grey66]new pwsh sessions will LOOK for CurrentUser modules there.[/]'
    # each Write-PSMMLine is its own Markup: tags must balance PER LINE
    Write-PSMMLine '[orange1]caveat (documented): Install-Module / Install-PSResource still INSTALL to the[/]'
    Write-PSMMLine '[orange1]default Documents-derived location - move existing module folders yourself, or[/]'
    Write-PSMMLine '[orange1]keep using d (download) / k (pin) to make the OneDrive copies reliable.[/]'
    $suggestion = if ($IsWindows) { Join-Path $HOME 'PowerShell\Modules' } else { '' }
    $path = Read-SpectreText -Message 'New primary module path (empty cancels)' -DefaultAnswer $suggestion -AllowEmpty
    if ([string]::IsNullOrWhiteSpace($path)) { return '[grey66]cancelled[/]' }
    try {
        if (-not (Test-Path -LiteralPath $path)) {
            if (Read-SpectreConfirm -Message "Create $path now?" -DefaultAnswer 'y') {
                New-Item -ItemType Directory -Force -Path $path | Out-Null
            }
        }
        $null = Set-PSMMUserModulePath -Path $path
        # The config override only applies to NEW sessions; prepend to the
        # live search path too so the new location is first - and visible in
        # the locations table - right away.
        $sep = [System.IO.Path]::PathSeparator
        $norm = $path.TrimEnd('\', '/')
        $rest = @($env:PSModulePath -split $sep |
            Where-Object { $_ -and ($_.TrimEnd('\', '/') -ne $norm) })
        $env:PSModulePath = (@($path) + $rest) -join $sep
        "[green3]primary location set - first in this session now, and in every NEW pwsh session ($(ConvertTo-PSMMSafe $path))[/]"
    } catch { "[indianred1]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" }
}