public/controls/display/New-UiLink.ps1

function New-UiLink {
    <#
    .SYNOPSIS
        Creates a clickable hyperlink that opens a URL or runs a custom action.
    .DESCRIPTION
        Creates a TextBlock styled as a hyperlink with underline and accent color.
        By default opens the URL in the system browser. Use -Action for custom behavior.
    .PARAMETER Text
        The display text for the link. Defaults to the URL if not specified.
    .PARAMETER Url
        The URL to open when clicked. Opens in the default browser; http and https only,
        anything else is blocked with a warning.
    .PARAMETER Action
        Custom scriptblock to run instead of opening a URL. Overrides -Url behavior.
    .PARAMETER NoUnderline
        Removes the underline decoration from the link text.
    .PARAMETER WPFProperties
        Hashtable of additional WPF properties to set on the control.
    .EXAMPLE
        New-UiLink -Url 'https://github.com' -Text 'Visit GitHub'
    .EXAMPLE
        New-UiLink -Text 'Open Settings' -Action { Show-SettingsDialog }
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$Text,

        [Parameter()]
        [string]$Url,

        [Parameter()]
        [scriptblock]$Action,

        [switch]$NoUnderline,

        [Parameter()]
        [hashtable]$WPFProperties
    )

    # Grab session and theme context
    $session     = Assert-UiSession -CallerName 'New-UiLink'
    $colors      = Get-ThemeColors
    $parent      = $session.CurrentParent
    $displayText = if ($Text) { $Text } else { $Url }

    # Can't create a link with nothing to show
    if (!$displayText) {
        throw "New-UiLink: Either -Text or -Url must be specified."
    }

    # Build the link with accent color and hand cursor
    $link = [System.Windows.Controls.TextBlock]@{
        Text         = $displayText
        FontFamily   = [System.Windows.Media.FontFamily]::new('Segoe UI Variable, Segoe UI')
        FontSize     = 13
        Foreground   = ConvertTo-UiBrush $colors.Accent
        Cursor       = 'Hand'
        Margin       = [System.Windows.Thickness]::new(4, 2, 4, 2)
    }

    # Apply underline unless explicitly disabled
    if (!$NoUnderline) {
        $link.TextDecorations = [System.Windows.TextDecorations]::Underline
    }

    # Capture action context at creation time (like New-UiButton does)
    $capturedVars    = $null
    $capturedFuncs   = $null
    $resolvedModules = $null
    $scopeNames      = $null
    
    if ($Action) {
        $ctxParams = @{
            Action          = $Action
            LinkedVariables = @()
            LinkedFunctions = @()
            LinkedModules   = @()
        }
        $actionContext   = Get-UiActionContext @ctxParams
        $capturedVars    = $actionContext.CapturedVars
        $capturedFuncs   = $actionContext.CapturedFuncs
        $resolvedModules = $actionContext.LinkedModules
        $scopeNames      = @($actionContext.AutoDetectedVars)
    }

    # Store action data in Tag (same structure as New-UiButton)
    $link.Tag = @{
        Busy          = $false
        BrushTag      = 'AccentBrush'
        Url           = $Url
        Action        = $Action
        CapturedVars  = $capturedVars
        ScopeNames    = $scopeNames
        CapturedFuncs = $capturedFuncs
        LinkedModules = $resolvedModules
        SessionId     = $session.SessionId
    }

    $pushSession = ${function:Push-UiSession}
    $popSession  = ${function:Pop-UiSession}

    # Click runs the action async or opens the URL in the browser
    $link.Add_MouseLeftButtonUp({
        param($sender, $eventArgs)
        $data = $sender.Tag
        Write-Debug "New-UiLink: Click detected. Action=$($null -ne $data.Action), Url=$($data.Url)"
        
        if ($data.Action) {
            # A second click while the first run is still going would build a second AsyncExecutor and put it in ActiveExecutor over the top of the first, so Stop-UiAsync could only ever reach the newer one.
            # Use a flag rather than IsEnabled, since this is a TextBlock and disabling it greys the text out, which would look as broken instead of busy.
            if ($data.Busy) { return }
            $data.Busy    = $true
            $data.Started = $false

            Write-Debug "New-UiLink: Executing custom action via AsyncExecutor"

            # The AsyncExecutor reads the thread's session in its constructor
            $sessionToken          = & $pushSession -SessionId $data.SessionId
            $executor              = [PsUi.AsyncExecutor]::new()
            $executor.UiDispatcher = [System.Windows.Threading.Dispatcher]::CurrentDispatcher

            # Store the AsyncExecutor in the session for Stop-UiAsync cancellation
            $linkSession = [PsUi.SessionManager]::Current
            if ($linkSession) {
                $linkSession.ActiveExecutor = $executor
                Write-Debug "New-UiLink: Executor stored in session $($linkSession.SessionId)"
            }

            # Route async output so Write-Host/Write-Warning/errors are visible
            $executor.add_OnHost({ param($hostRecord) Write-Host $hostRecord.Message })
            $executor.add_OnWarning({ param($warningMsg) Write-Warning $warningMsg })
            # OnStarted only fires once a thread is in hand. All 8 taken for 60s and the run is abandoned with an error, so OnError has to do the whole teardown itself in that one case or the link never answers again.
            $executor.add_OnStarted({ $data.Started = $true }.GetNewClosure())
            $executor.add_OnError({
                param($errorRecord)
                Write-Warning "Link action error: $($errorRecord.Message)"
                if ($data.Started) { return }
                $executor.Dispose()
                $data.Busy = $false
                if ($linkSession -and [object]::ReferenceEquals($linkSession.ActiveExecutor, $executor)) { $linkSession.ActiveExecutor = $null }
            }.GetNewClosure())

            # If a newer run owns ActiveExecutor by now, this one finishing must not clear it.
            $executor.add_OnComplete({
                Write-Debug "New-UiLink: Action completed"
                $executor.Dispose()
                $data.Busy = $false
                if ($linkSession -and [object]::ReferenceEquals($linkSession.ActiveExecutor, $executor)) { $linkSession.ActiveExecutor = $null }
            }.GetNewClosure())

            $executor.add_OnCancelled({
                Write-Debug "New-UiLink: Action cancelled"
                $executor.Dispose()
                $data.Busy = $false
                if ($linkSession -and [object]::ReferenceEquals($linkSession.ActiveExecutor, $executor)) { $linkSession.ActiveExecutor = $null }
            }.GetNewClosure())
            
            # Build variables dict with theme colors (same as New-UiButton)
            $currentThemeColors = Get-ThemeColors
            $varsWithTheme = if ($data.CapturedVars) { $data.CapturedVars.Clone() } else { @{} }
            $varsWithTheme = Remove-UiStoreShadow -Variables $varsWithTheme -AutoNames $data.ScopeNames
            if ($currentThemeColors) {
                $varsWithTheme['__WPFThemeColors'] = $currentThemeColors
            }
            
            $executor.ExecuteAsync(
                $data.Action,
                $null,
                $varsWithTheme,
                $data.CapturedFuncs,
                [string[]]@($data.LinkedModules | Where-Object { $_ })
            )
            & $popSession -Token $sessionToken
            Write-Debug "New-UiLink: ExecuteAsync called"
        }
        elseif ($data.Url) {
            # Only allow http/https schemes
            $urlToOpen = $data.Url
            Write-Debug "New-UiLink: Opening URL $urlToOpen"
            if ($urlToOpen -match '^https?://[^\s]+$') {
                Start-Process $urlToOpen
            }
            else {
                Write-Warning "New-UiLink: Blocked opening URL with invalid scheme. Only http/https URLs are allowed: $urlToOpen"
            }
        }
    }.GetNewClosure())

    # Subtle opacity change on hover for visual feeback
    $link.Add_MouseEnter({ param($sender, $eventArgs) $sender.Opacity = 0.7 })
    $link.Add_MouseLeave({ param($sender, $eventArgs) $sender.Opacity = 1.0 })

    # Register for dynamic theme switching
    [PsUi.ThemeEngine]::RegisterElement($link)

    if ($WPFProperties) {
        Set-UiProperties -Control $link -Properties $WPFProperties
    }

    Add-UiControlToParent -Control $link -Parent $parent
}