Public/Read-NSPFolderPath.ps1

function Read-NSPFolderPath {
    <#
    .SYNOPSIS
        Asks for a folder: Enter opens a Windows folder picker, or the path can be typed or pasted.
    .DESCRIPTION
        Returns the full path of a folder. Surrounding quotes are removed, so a path copied with
        Explorer's "Copy as path" pastes as-is. A missing folder is reported and asked again - or,
        with -Create, offered to be created (created without asking when -Answer is used).
 
        The picker is the folder counterpart of Read-NSPFilePath's: Windows blocks dropping a folder
        onto an ELEVATED console, so it is how a tech "drags" one in. It runs on its own STA thread
        (PowerShell 7 consoles are MTA) and is skipped where there is no desktop - a remote session,
        Server Core, or NSP_NO_FILEDIALOG=1 - leaving the typed path. Blank with -Default returns
        the default instead of opening the picker.
 
        -AllowBack lets B or Back return the back signal (see Test-NSPBackSignal). -Answer supports
        noninteractive callers and tests: it is used as the typed path, and a missing folder throws
        (unless -Create).
    .EXAMPLE
        $dir = Read-NSPFolderPath -Prompt 'Capture folder' -Default 'D:\Captures' -Create
    .EXAMPLE
        $src = Read-NSPFolderPath -Prompt 'Source folder' -InitialDirectory $env:USERPROFILE -AllowBack
        if (Test-NSPBackSignal $src) { return }
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][string]$Prompt,
        [string]$Default,
        [string]$InitialDirectory,
        [switch]$Create,
        [switch]$AllowBack,
        [AllowEmptyString()][string]$Answer
    )

    $provided = $PSBoundParameters.ContainsKey('Answer')
    $canBrowse = $env:NSP_NO_FILEDIALOG -ne '1' -and [Environment]::UserInteractive -and $Host.Name -ne 'ServerRemoteHost'
    if (-not $InitialDirectory -and $Default) { $InitialDirectory = $Default }
    $showDialog = {
        $dialog = {
            param($Title, $InitialDirectory)
            Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
            $owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false }
            $picker = New-Object System.Windows.Forms.FolderBrowserDialog -Property @{ Description = $Title; ShowNewFolderButton = $true }
            # .NET Core 3+ (PowerShell 7) shows the modern dialog and can use the description as its title.
            if ($picker.PSObject.Properties['UseDescriptionForTitle']) { $picker.UseDescriptionForTitle = $true }
            if ($InitialDirectory -and (Test-Path -LiteralPath $InitialDirectory -PathType Container)) { $picker.SelectedPath = $InitialDirectory }
            try { if ($picker.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) { $picker.SelectedPath } }
            finally { $picker.Dispose(); $owner.Dispose() }
        }
        try {
            if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -eq 'STA') {
                return & $dialog $Prompt $InitialDirectory
            }
            $runspace = [runspacefactory]::CreateRunspace()
            $runspace.ApartmentState = 'STA'
            $runspace.Open()
            $ps = [powershell]::Create()
            $ps.Runspace = $runspace
            try { return @($ps.AddScript($dialog.ToString()).AddArgument($Prompt).AddArgument($InitialDirectory).Invoke()) | Select-Object -First 1 }
            finally { $ps.Dispose(); $runspace.Dispose() }
        } catch {
            Write-Verbose "Folder picker unavailable: $($_.Exception.Message)"
            return $null
        }
    }

    $backHint = if ($AllowBack) { ' [B = back]' } else { '' }
    $defaultHint = if ($Default) { " (blank for '$Default')" } else { '' }
    $browseHint = if ($Default) { $(if ($canBrowse) { ', ? to browse, or paste a path' } else { ', or type or paste a path' }) }
    elseif ($canBrowse) { ' - Enter to browse, or paste a path' } else { ' - type or paste the path' }
    while ($true) {
        $response = if ($provided) { $Answer } else { Read-Host ($Prompt + $defaultHint + $browseHint + $backHint) }
        if ($AllowBack -and $response -match '^(?i)b(ack)?$') { return $script:NSPBackSignal }
        $path = "$response".Trim().Trim('"', "'").Trim()

        if ($path -eq '?' -or (-not $path -and -not $Default)) {
            if ($provided) { throw "$Prompt`: no folder given." }
            if (-not $canBrowse) { Write-Host ' Type or paste the full path.' -ForegroundColor Yellow; continue }
            $path = & $showDialog
            if (-not $path) { Write-Host ' No folder chosen - pick one, or type or paste the path.' -ForegroundColor Yellow; continue }
        } elseif (-not $path) {
            $path = $Default
        }

        if (Test-Path -LiteralPath $path -PathType Container) { return (Resolve-Path -LiteralPath $path).ProviderPath }
        if (Test-Path -LiteralPath $path -PathType Leaf) {
            if ($provided) { throw "$Prompt`: that is a file, not a folder: $path" }
            Write-Host " That is a file, not a folder: $path" -ForegroundColor Yellow
            continue
        }
        if ($Create -and ($provided -or (Read-NSPConfirm -Prompt " $path does not exist. Create it?" -DefaultYes))) {
            try {
                New-Item -ItemType Directory -Path $path -Force -ErrorAction Stop | Out-Null
                return (Resolve-Path -LiteralPath $path).ProviderPath
            } catch {
                if ($provided) { throw "$Prompt`: could not create ${path}: $($_.Exception.Message)" }
                Write-Host " Could not create ${path}: $($_.Exception.Message)" -ForegroundColor Yellow
                continue
            }
        }
        if ($provided) { throw "$Prompt`: folder not found: $path" }
        Write-Host " Folder not found: $path" -ForegroundColor Yellow
    }
}