src/UI/75-Paths.ps1

# 75-Paths.ps1 — module locations screen: every PSModulePath entry with
# OneDrive/cloud-placeholder diagnostics, download (hydrate) and pin actions,
# adding a location, moving a location's contents elsewhere, 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 - 16)
    $rows = [System.Collections.Generic.List[string[]]]::new()
    for ($i = $vp.First; $i -le $vp.Last; $i++) {
        $p = $Infos[$i]
        $nm = ConvertTo-PSMMSafe (Get-PSMMTrunc $p.Path ([Math]::Max(20, $win.Width - 52)))
        if ($i -eq $State.Cursor) { $nm = "[bold $script:PSMM_ColAccent]$nm[/]" }
        $notes = @()
        if ($p.First) { $notes += "[$script:PSMM_ColAccent]first[/]" }
        if ($p.UserDefault) { $notes += 'user default' }
        if ($p.OneDrive) { $notes += "[$script:PSMM_ColWarn]onedrive[/]" }
        if (-not $p.Exists) { $notes += "[$script:PSMM_ColErr]missing[/]" }
        elseif ($p.PSObject.Properties['Writable'] -and -not $p.Writable) { $notes += "[$script:PSMM_ColDim]ro[/]" }
        $mods = if ($p.PSObject.Properties['ModuleCount'] -and $null -ne $p.ModuleCount) { "$($p.ModuleCount)" } else { '-' }
        $rows.Add([string[]]@("$($p.Order + 1)", $nm, "[$script:PSMM_ColDim]$mods[/]", ($notes -join ' ')))
    }
    $T = New-PSMMTable -Headers @('#', 'path', 'modules', 'notes') -Rows $rows -CursorRow ($State.Cursor - $vp.First)
    $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((Get-PSMMHeaderBar -Breadcrumb @('home', 'paths') -CountsMarkup "[$script:PSMM_ColDim](`$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.
    # Wrapped at a readable measure - this paragraph used to run the full
    # terminal width (gh#11).
    $first = if ($n) { $Infos[0] } else { $null }
    if ($first -and $first.OneDrive) {
        $items.Add([Spectre.Console.Markup]::new("[$script:PSMM_ColWarn]your primary module location is inside OneDrive[/]"))
        $prose = "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, m moves the modules ' +
                 'that are already there.'
        foreach ($l in (Get-PSMMProseMarkup -Text $prose)) { $items.Add([Spectre.Console.Markup]::new($l)) }
    }
    $items.Add([Spectre.Console.Markup]::new((Get-PSMMHint -Pairs @('d=download cloud-only files', 'k=keep on device (pin)', 'n=add a location'))))
    $items.Add([Spectre.Console.Markup]::new((Get-PSMMHint -Pairs @('s=set primary location', 'r=remove primary override', 'm=move contents elsewhere'))))
    $items.Add([Spectre.Console.Markup]::new((Get-PSMMHint -Pairs @('left/right=back / details') -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)
}

# One PSModulePath entry, enriched for the screen: writability and how many
# module folders it holds. Done ONCE per screen refresh, never per frame - it
# is a directory listing per root, and one of those roots may be in OneDrive.
function script:Get-PSMMPathScreenInfo {
    @(foreach ($p in (Get-PSMMModulePathInfo)) {
            $count = $null
            $writable = $false
            if ($p.Exists) {
                try { $count = @(Get-ChildItem -LiteralPath $p.Path -Directory -Force -ErrorAction SilentlyContinue).Count } catch { }
                $writable = Test-PSMMDirectoryWritable -Path $p.Path
            }
            $p | Add-Member -NotePropertyName ModuleCount -NotePropertyValue $count -Force
            $p | Add-Member -NotePropertyName Writable -NotePropertyValue $writable -Force
            $p
        })
}

function script:Show-PSMMPaths {
    $ui = $script:PSMM_UI
    $st = New-PSMMListState
    $st.Status = ''
    while ($true) {
        if ($ui.HardQuit -or $ui.Goto) { return }
        $infos = @(Get-PSMMPathScreenInfo)
        $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 ($k.KeyChar -eq 'g') {
                    $dest = Read-PSMMGotoKey -BaseRenderable (Build-PSMMPathsView -State $st -Infos $infos -StatusMarkup $st.Status)
                    if ($dest) { $script:PSMM_UI.Goto = $dest; return }
                    continue
                }
                if (Test-PSMMHomeKey $k) { $script:PSMM_UI.Goto = 'home'; return }
                $st.Status = ''
                if (Invoke-PSMMListNav -State $st -KeyInfo $k -Count $infos.Count) { continue }
                # left/right work here exactly as everywhere else (gh#7)
                $drill = Get-PSMMDrillKey -KeyInfo $k
                if ($drill -eq 'out') { return }
                if ($drill -eq 'in') { $cmd.Name = 'details'; return }
                # verbs are plain letters: a ctrl chord must never trigger one
                # (ctrl+m in particular is Enter on many terminals, and 'm'
                # moves module folders)
                $ctrl = ($k.Modifiers -band [ConsoleModifiers]::Control) -ne 0
                switch ($k.Key) {
                    ([ConsoleKey]::Enter)  { $cmd.Name = 'details'; return }
                    ([ConsoleKey]::D)      { if (-not $ctrl) { $cmd.Name = 'download'; return }; continue }
                    ([ConsoleKey]::K)      { if (-not $ctrl) { $cmd.Name = 'pin'; return }; continue }
                    ([ConsoleKey]::S)      { if (-not $ctrl) { $cmd.Name = 'setprimary'; return }; continue }
                    ([ConsoleKey]::R)      { if (-not $ctrl) { $cmd.Name = 'clearprimary'; return }; continue }
                    ([ConsoleKey]::N)      { if (-not $ctrl) { $cmd.Name = 'newlocation'; return }; continue }
                    ([ConsoleKey]::M)      { if (-not $ctrl) { $cmd.Name = 'movecontent'; return }; continue }
                    ([ConsoleKey]::Escape) { return }
                    default { if ($k.KeyChar -eq '?') { $cmd.Name = 'help'; return } }
                }
            }
        }
        if ($ui.HardQuit -or $ui.Goto) { return }
        $cur = if ($infos.Count) { $infos[[Math]::Min($st.Cursor, $infos.Count - 1)] } else { $null }
        switch ($cmd.Name) {
            'help' { Show-PSMMHelpScreen -Topic 'paths' }
            'details' { if ($cur) { Show-PSMMPathDetails -Info $cur } }
            'download' {
                if ($cur) { $st.Status = Invoke-PSMMPathDownload -Info $cur }
            }
            'pin' {
                if ($cur) { $st.Status = Invoke-PSMMPathPin -Info $cur }
            }
            'newlocation' { $st.Status = Add-PSMMLocationUI }
            'movecontent' { if ($cur) { $st.Status = Move-PSMMLocationUI -Info $cur -All $infos } }
            'setprimary' { $st.Status = Set-PSMMPrimaryLocationUI }
            'clearprimary' {
                Clear-PSMMScreen
                Write-PSMMLine "[$script:PSMM_ColAccent]Remove the primary-location override[/]"
                $cfg = Get-PSMMUserConfigJsonPath
                Write-PSMMLine "[$script:PSMM_ColMute]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 = "[$script:PSMM_ColOk]override removed - takes effect in NEW pwsh sessions[/]"
                    } catch { $st.Status = "[$script:PSMM_ColErr]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" }
                } else { $st.Status = "[$script:PSMM_ColMute]cancelled[/]" }
            }
            default { return }
        }
    }
}

# What one location holds, as a pager (the drill-in target for right/enter).
function script:Show-PSMMPathDetails {
    param([Parameter(Mandatory)] $Info)
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]reading $(ConvertTo-PSMMSafe $Info.Path)...[/]"
    $lines = [System.Collections.Generic.List[string]]::new()
    $lines.Add("path $($Info.Path)")
    $lines.Add("search order $($Info.Order + 1)$(if ($Info.First) { ' (first - this is where PowerShell looks first)' })")
    $lines.Add("exists $(if ($Info.Exists) { 'yes' } else { 'no' })")
    $lines.Add("writable $(if ($Info.PSObject.Properties['Writable'] -and $Info.Writable) { 'yes' } else { 'no' })")
    $lines.Add("user default $(if ($Info.UserDefault) { 'yes - derived from your Documents folder' } else { 'no' })")
    $lines.Add("onedrive $(if ($Info.OneDrive) { 'yes - files here can be cloud-only placeholders' } else { 'no' })")
    $lines.Add('')
    $mods = @(Get-PSMMLocationModule -Path $Info.Path)
    $total = 0L
    foreach ($m in $mods) { $total += $m.Bytes }
    $lines.Add("$($mods.Count) module folder(s), $(Format-PSMMSize $total)")
    $lines.Add('')
    foreach ($m in ($mods | Sort-Object -Property @{ Expression = 'Bytes'; Descending = $true })) {
        $lines.Add((" {0,-44} {1,4} version(s) {2,10}" -f (Get-PSMMTrunc $m.Name 44), $m.Versions, (Format-PSMMSize $m.Bytes)))
    }
    Show-PSMMPager -Lines $lines -TitleMarkup "[$script:PSMM_ColAccent]Location details[/]" -Breadcrumb @('home', 'paths', 'details')
}

# Scan one PSModulePath entry for cloud-only files and hydrate them, with a
# concurrency prompt first (gh#14) 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 "[$script:PSMM_ColMute]$(ConvertTo-PSMMSafe $Info.Path)[/]"
    if (-not $Info.Exists) { $null = Wait-PSMMKey; return "[$script:PSMM_ColWarn]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 "[$script:PSMM_ColOk]no cloud-only files - everything is already on disk[/]" }
    $mb = [Math]::Round((($files | Measure-Object Length -Sum).Sum) / 1MB, 1)
    Write-PSMMLine "[$script:PSMM_ColWarn]$($files.Count) cloud-only file(s), $mb MB to download[/]"
    $parallel = Read-PSMMNumber -Title 'Download cloud-only files' `
        -Message "$($files.Count) file(s), $mb MB. Each download waits on OneDrive, so several at once finish much sooner than one after the other." `
        -Default (Get-PSMMHydrationDefault) -Min 1 -Max (Get-PSMMHydrationMax) `
        -MaxReason (Get-PSMMHydrationMaxReason) -Unit 'at a time'
    if ($null -eq $parallel) { return "[$script:PSMM_ColMute]download cancelled[/]" }
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]downloading $($files.Count) file(s), $parallel at a time[/]"
    $r = Invoke-PSMMFileHydration -Files $files -ThrottleLimit $parallel -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 "[$script:PSMM_ColErr] $(ConvertTo-PSMMSafe $e)[/]" }
    $null = Wait-PSMMKey
    if ($r.Failed) { "[$script:PSMM_ColWarn]downloaded $($r.Ok), $($r.Failed) failed[/]" } else { "[$script:PSMM_ColOk]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 "[$script:PSMM_ColMute]$(ConvertTo-PSMMSafe $Info.Path)[/]"
    if (-not $Info.OneDrive) { $null = Wait-PSMMKey; return "[$script:PSMM_ColWarn]not a OneDrive path - nothing to pin[/]" }
    if (-not $Info.Exists) { $null = Wait-PSMMKey; return "[$script:PSMM_ColWarn]path does not exist[/]" }
    Write-PSMMProse -Text '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 "[$script:PSMM_ColMute]pin cancelled[/]" }
    try {
        Invoke-PSMMPinPath -Path $Info.Path
        "[$script:PSMM_ColOk]pinned - OneDrive is downloading the files in the background[/]"
    } catch { "[$script:PSMM_ColErr]$(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 "[$script:PSMM_ColWarn]$($files.Count) file(s) of $(ConvertTo-PSMMSafe $ModuleName) are OneDrive cloud-only ($mb MB)[/]"
    Write-PSMMProse -Text '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 }
    $par = Get-PSMMHydrationDefault
    $r = Invoke-PSMMFileHydration -Files $files -ThrottleLimit $par -OnProgress {
        param($i, $total, $f)
        Write-PSMMLine "[$script:PSMM_ColMute] downloading ($i/$total) $(ConvertTo-PSMMSafe $f.Name)[/]"
    }
    if ($r.Failed) { Write-PSMMLine "[$script:PSMM_ColWarn]$($r.Failed) file(s) failed to download - the load may still fail[/]" }
    $true
}

# Add a folder to the module search path, creating it when it does not exist
# yet (gh#12).
function script:Add-PSMMLocationUI {
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]Add a module location[/]"
    Write-PSMMProse -Text ('PowerShell looks for modules in every folder on $env:PSModulePath, in order. ' +
        'Adding one here takes effect in this session immediately; persisting it makes new sessions see it too.')
    $suggestion = if ($IsWindows) { Join-Path $HOME 'PowerShell\Modules' } else { Join-Path $HOME '.local/share/powershell/Modules' }
    $path = Read-PSMMText -Message 'Folder (empty cancels)' -DefaultAnswer $suggestion -AllowEmpty
    if ([string]::IsNullOrWhiteSpace($path)) { return "[$script:PSMM_ColMute]cancelled[/]" }
    $path = "$path".Trim()
    if (Test-PSMMModulePathContains -Path $path) { $null = Wait-PSMMKey; return "[$script:PSMM_ColWarn]already on the module search path[/]" }
    if (-not (Test-Path -LiteralPath $path -PathType Container)) {
        if (-not (Read-SpectreConfirm -Message "$(ConvertTo-PSMMSafe $path) does not exist - create it?" -DefaultAnswer 'y')) { return "[$script:PSMM_ColMute]cancelled[/]" }
        try { New-Item -ItemType Directory -Force -Path $path | Out-Null }
        catch { $null = Wait-PSMMKey; return "[$script:PSMM_ColErr]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" }
    }
    if (-not (Test-PSMMDirectoryWritable -Path $path)) {
        $null = Wait-PSMMKey
        return "[$script:PSMM_ColErr]that folder is not writable - modules could be read from it, but never installed there[/]"
    }
    $where = Read-SpectreSelection -Message 'Search order' -Choices 'after the existing locations', 'first (searched before everything else)' -Color $script:PSMM_ColAccent
    $first = ($where -like 'first*')
    $null = Add-PSMMModulePath -Path $path -First:$first
    $note = "added to this session's module search path"
    if ($IsWindows) {
        Write-PSMMProse -Text ('psmm can persist this in your user PSModulePath environment variable, which PowerShell ' +
            'merges into every new session. It does not change where Install-PSResource installs to.')
        if ($first) {
            # be honest: PowerShell composes the final search path itself, and
            # a user-scope entry cannot be forced ahead of the CurrentUser
            # default - only s (set primary location) moves what comes first
            Write-PSMMProse -Text ('"first" applies to this session only. PowerShell builds the search order for a new ' +
                'session itself, so a persisted entry lands after the built-in locations - use s to change which ' +
                'location comes first for good.') -Colour $script:PSMM_ColWarn
        }
        if (Read-SpectreConfirm -Message 'Persist it for new sessions?' -DefaultAnswer 'y') {
            try {
                $null = Add-PSMMPersistentModulePath -Path $path
                $note = 'added to this session and persisted in your user PSModulePath'
            } catch { Write-PSMMLine "[$script:PSMM_ColErr]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" }
        }
    } else {
        Write-PSMMProse -Text 'to keep it for new sessions, add it to $env:PSModulePath in your $PROFILE.'
    }
    $null = Wait-PSMMKey
    "[$script:PSMM_ColOk]$note ($(ConvertTo-PSMMSafe $path))[/]"
}

# Move every module folder from one location to another (gh#13).
# Gated behind a typed phrase: this is destructive and sits two keystrokes
# away from plain navigation, so y/n is not enough.
function script:Move-PSMMLocationUI {
    param(
        [Parameter(Mandatory)] $Info,
        [Parameter(Mandatory)] $All
    )
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]Move the contents of a module location[/]"
    Write-PSMMLine "[$script:PSMM_ColMute]from[/] [$script:PSMM_ColDim]$(ConvertTo-PSMMSafe $Info.Path)[/]"
    if (-not $Info.Exists) { $null = Wait-PSMMKey; return "[$script:PSMM_ColWarn]path does not exist[/]" }
    Write-PSMMLine 'reading the folder...'
    $mods = @(Get-PSMMLocationModule -Path $Info.Path)
    if (-not $mods.Count) { $null = Wait-PSMMKey; return "[$script:PSMM_ColOk]no module folders here - nothing to move[/]" }

    $targets = @($All | Where-Object {
            $_.Path.TrimEnd('\', '/') -ne $Info.Path.TrimEnd('\', '/') -and $_.Exists -and
            (Test-PSMMDirectoryWritable -Path $_.Path)
        } | Select-Object -ExpandProperty Path)
    if (-not $targets.Count) {
        Write-PSMMProse -Text 'No other writable location on the module search path. Add one first with n.' -Colour $script:PSMM_ColWarn
        $null = Wait-PSMMKey
        return "[$script:PSMM_ColWarn]no writable target location[/]"
    }
    $target = if ($targets.Count -eq 1) { $targets[0] } else { Read-SpectreSelection -Message 'Move everything to' -Choices $targets -Color $script:PSMM_ColAccent }
    if (-not $target) { return "[$script:PSMM_ColMute]move cancelled[/]" }

    # what will be skipped, decided BEFORE anything moves
    $loaded = @(Get-Module | Select-Object -ExpandProperty Name)
    $loadedHere = @($mods | Where-Object { $loaded -contains $_.Name } | Select-Object -ExpandProperty Name)
    $existing = @(Get-PSMMLocationModule -Path $target | Select-Object -ExpandProperty Name)
    $collide = @($mods | Where-Object { $existing -contains $_.Name } | Select-Object -ExpandProperty Name)
    $movable = @($mods | Where-Object { $loadedHere -notcontains $_.Name -and $collide -notcontains $_.Name })
    # size of what will ACTUALLY move, not of everything in the folder -
    # quoting the total next to the movable count overstates the operation
    $total = 0L
    foreach ($m in $movable) { $total += $m.Bytes }

    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]Move module folders[/]"
    Write-PSMMLine "[$script:PSMM_ColMute]from[/] [$script:PSMM_ColDim]$(ConvertTo-PSMMSafe $Info.Path)[/]"
    Write-PSMMLine "[$script:PSMM_ColMute]to[/] [$script:PSMM_ColDim]$(ConvertTo-PSMMSafe $target)[/]"
    Write-PSMMLine "[$script:PSMM_ColOk]$($movable.Count) of $($mods.Count) folder(s) will move[/] [$script:PSMM_ColMute]($(Format-PSMMSize $total))[/]"
    if ($loadedHere.Count) {
        Write-PSMMProse -Text "skipped, imported in this session (their files are in use): $($loadedHere -join ', ')" -Colour $script:PSMM_ColWarn
    }
    if ($collide.Count) {
        Write-PSMMProse -Text "skipped, already present in the target: $($collide -join ', ')" -Colour $script:PSMM_ColWarn
    }
    if (-not $movable.Count) { $null = Wait-PSMMKey; return "[$script:PSMM_ColWarn]nothing movable - everything is loaded or already there[/]" }
    if (-not (Read-PSMMConfirmPhrase -Phrase 'really move' -Warning 'Module folders are moved on disk. Anything that references the old path (shortcuts, scripts pinning a full path) will stop working.')) {
        return "[$script:PSMM_ColMute]move cancelled - nothing was touched[/]"
    }
    Clear-PSMMScreen
    Write-PSMMLine "[$script:PSMM_ColAccent]moving $($movable.Count) folder(s)...[/]"
    # each skip carries the reason it was skipped, so the report is truthful
    $skip = @{}
    foreach ($n in $loadedHere) { $skip[$n] = 'imported in this session - unload it first' }
    foreach ($n in $collide) { $skip[$n] = 'a module of that name is already in the target' }
    $results = @(Move-PSMMLocationContent -Source $Info.Path -Target $target -Skip $skip -OnProgress {
            param($i, $n, $name)
            Write-PSMMLine "[$script:PSMM_ColMute] ($i/$n) $(ConvertTo-PSMMSafe $name)[/]"
        })
    $moved = @($results | Where-Object Moved).Count
    foreach ($r in @($results | Where-Object { -not $_.Moved } | Select-Object -First 8)) {
        Write-PSMMLine "[$script:PSMM_ColWarn] skipped $(ConvertTo-PSMMSafe $r.Name): $(ConvertTo-PSMMSafe $r.Reason)[/]"
    }
    $script:PSMM_UI.Dirty = $true
    $null = Wait-PSMMKey
    if ($moved -eq $results.Count) { "[$script:PSMM_ColOk]moved $moved module folder(s) to $(ConvertTo-PSMMSafe $target)[/]" }
    else { "[$script:PSMM_ColWarn]moved $moved of $($results.Count) - the rest were skipped[/]" }
}

# 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 "[$script:PSMM_ColMute]writes the documented PSModulePath override to $(ConvertTo-PSMMSafe "$cfg")[/]"
    Write-PSMMLine "[$script:PSMM_ColMute]new pwsh sessions will LOOK for CurrentUser modules there.[/]"
    Write-PSMMProse -Colour $script:PSMM_ColWarn -Text ('caveat (documented): Install-Module / Install-PSResource still INSTALL to the ' +
        'default Documents-derived location - move existing module folders yourself (m on this screen, or p in a ' +
        'module menu), or keep using d (download) / k (pin) to make the OneDrive copies reliable.')
    $suggestion = if ($IsWindows) { Join-Path $HOME 'PowerShell\Modules' } else { '' }
    $path = Read-PSMMText -Message 'New primary module path (empty cancels)' -DefaultAnswer $suggestion -AllowEmpty
    if ([string]::IsNullOrWhiteSpace($path)) { return "[$script:PSMM_ColMute]cancelled[/]" }
    try {
        if (-not (Test-Path -LiteralPath $path)) {
            if (Read-SpectreConfirm -Message "Create $(ConvertTo-PSMMSafe $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
        "[$script:PSMM_ColOk]primary location set - first in this session now, and in every NEW pwsh session ($(ConvertTo-PSMMSafe $path))[/]"
    } catch { "[$script:PSMM_ColErr]$(ConvertTo-PSMMSafe $_.Exception.Message)[/]" }
}