PSRedditTUI.psm1
|
# PSRedditTUI - PowerShell Reddit Terminal UI Module # Requires PowerShell Core 7+ #region Logging $script:LogFile = Join-Path ([System.IO.Path]::GetTempPath()) "psreddittui-debug.log" $script:LogLevel = "Debug" # Debug, Info, Warning, Error function Write-Log { <# .SYNOPSIS Writes a log entry to the log file .PARAMETER Message The message to log .PARAMETER Level The log level (Debug, Info, Warning, Error) .PARAMETER Exception Optional exception object for error logging .PARAMETER ErrorRecord Optional ErrorRecord object for detailed error logging #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Message, [Parameter(Mandatory = $false)] [ValidateSet('Debug', 'Info', 'Warning', 'Error')] [string]$Level = 'Info', [Parameter(Mandatory = $false)] [System.Exception]$Exception, [Parameter(Mandatory = $false)] [System.Management.Automation.ErrorRecord]$ErrorRecord ) $levels = @{ 'Debug' = 0; 'Info' = 1; 'Warning' = 2; 'Error' = 3 } $currentLevel = $levels[$script:LogLevel] $messageLevel = $levels[$Level] if ($messageLevel -lt $currentLevel) { return } $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" $caller = (Get-PSCallStack)[1] $callerInfo = if ($caller.FunctionName -and $caller.FunctionName -ne '<ScriptBlock>') { $caller.FunctionName } else { "Module" } $logEntry = "[$timestamp] [$Level] [$callerInfo] $Message" # Add exception details if ($Exception) { $logEntry += "`n Exception Type: $($Exception.GetType().FullName)" $logEntry += "`n Exception Message: $($Exception.Message)" if ($Exception.InnerException) { $logEntry += "`n Inner Exception: $($Exception.InnerException.GetType().Name): $($Exception.InnerException.Message)" } if ($Exception.StackTrace) { $logEntry += "`n StackTrace:`n $($Exception.StackTrace -replace "`n", "`n ")" } } # Add ErrorRecord details (more PowerShell-specific info) if ($ErrorRecord) { $logEntry += "`n Error Category: $($ErrorRecord.CategoryInfo.Category)" $logEntry += "`n Error ID: $($ErrorRecord.FullyQualifiedErrorId)" $logEntry += "`n Error Type: $($ErrorRecord.Exception.GetType().FullName)" $logEntry += "`n Target Object: $($ErrorRecord.TargetObject)" # Add inner exception details if present if ($ErrorRecord.Exception.InnerException) { $logEntry += "`n Inner Exception: $($ErrorRecord.Exception.InnerException.GetType().FullName)" $logEntry += "`n Inner Message: $($ErrorRecord.Exception.InnerException.Message)" } if ($ErrorRecord.InvocationInfo) { $logEntry += "`n Script: $($ErrorRecord.InvocationInfo.ScriptName)" $logEntry += "`n Line: $($ErrorRecord.InvocationInfo.ScriptLineNumber)" $logEntry += "`n Command: $($ErrorRecord.InvocationInfo.MyCommand)" $logEntry += "`n Position: $($ErrorRecord.InvocationInfo.PositionMessage)" } if ($ErrorRecord.ScriptStackTrace) { $logEntry += "`n Script StackTrace:`n $($ErrorRecord.ScriptStackTrace -replace "`n", "`n ")" } # Add any additional error data if ($ErrorRecord.ErrorDetails) { $logEntry += "`n Error Details: $($ErrorRecord.ErrorDetails.Message)" } } try { $logEntry | Out-File -FilePath $script:LogFile -Append -Encoding utf8 } catch { # Silently fail if we can't write to log } } function Clear-PSRedditTUILog { <# .SYNOPSIS Clears the PSRedditTUI log file #> [CmdletBinding()] param() if (Test-Path $script:LogFile) { Remove-Item $script:LogFile -Force Write-Log -Message "Log file cleared" -Level Info } } function Get-PSRedditTUILog { <# .SYNOPSIS Gets the contents of the PSRedditTUI log file .PARAMETER Tail Number of lines to show from the end (default: all) #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [int]$Tail = 0 ) if (Test-Path $script:LogFile) { if ($Tail -gt 0) { Get-Content $script:LogFile -Tail $Tail } else { Get-Content $script:LogFile } } else { Write-Warning "Log file not found: $script:LogFile" } } function Set-PSRedditTUILogLevel { <# .SYNOPSIS Sets the logging level for PSRedditTUI .PARAMETER Level The log level (Debug, Info, Warning, Error) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateSet('Debug', 'Info', 'Warning', 'Error')] [string]$Level ) $script:LogLevel = $Level Write-Log -Message "Log level set to: $Level" -Level Info } #endregion # Auto-load Terminal.Gui and NStack if installed via Install-PSRedditTUITerminalGui & { $packageDir = Join-Path $HOME ".psreddittui-packages" $nstackDll = Join-Path $packageDir "NStack.Core/lib/netstandard2.0/NStack.dll" # Try to find Terminal.Gui.dll in order of preference: net8.0, net7.0, netstandard2.1 $terminalGuiDll = $null $possiblePaths = @( "Terminal.Gui/lib/net8.0/Terminal.Gui.dll", "Terminal.Gui/lib/net7.0/Terminal.Gui.dll", "Terminal.Gui/lib/netstandard2.1/Terminal.Gui.dll" ) foreach ($path in $possiblePaths) { $fullPath = Join-Path $packageDir $path if (Test-Path $fullPath) { $terminalGuiDll = $fullPath Write-Log -Message "Found Terminal.Gui at: $terminalGuiDll" -Level Debug break } } if ((Test-Path $nstackDll) -and $terminalGuiDll) { try { if (-not ([System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'NStack' })) { Add-Type -Path $nstackDll Write-Log -Message "Loaded NStack from: $nstackDll" -Level Debug } if (-not ([System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'Terminal.Gui' })) { Add-Type -Path $terminalGuiDll Write-Log -Message "Loaded Terminal.Gui from: $terminalGuiDll" -Level Info } } catch { Write-Log -Message "Failed to auto-load Terminal.Gui" -Level Error -ErrorRecord $_ Write-Warning "Failed to auto-load Terminal.Gui: $_" } } else { Write-Log -Message "Terminal.Gui not found at expected path" -Level Warning } } # Module variables $script:FavoritesFile = Join-Path $HOME ".psreddittui_favorites.json" $script:Favorites = @() $script:AfterCursor = $null $script:LastSearchQuery = $null $script:LastSearchSubreddit = $null $script:LastSearchGlobal = $false # Default subreddits to populate on first launch $script:DefaultFavorites = @( 'popular', 'all', 'powershell', 'windows', 'microsoft', 'technology', 'news', 'gaming', 'lifeprotips', 'todayilearned', 'askreddit' ) Write-Log -Message "Favorites file: $script:FavoritesFile" -Level Debug #region Reddit API Functions function Get-RedditData { <# .SYNOPSIS Fetches Reddit data in JSON format .DESCRIPTION Appends /.json to Reddit URLs and fetches the data .PARAMETER Url The Reddit URL to fetch (without .json extension) .EXAMPLE Get-RedditData -Url "https://www.reddit.com/r/powershell" #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Url ) $ProgressPreference = 'SilentlyContinue' try { # Add .json to the URL path (before any query string) if (-not ($Url -match '\.json')) { # Parse URL to insert .json before query string if ($Url -match '\?') { # URL has query string - insert .json before it $Url = $Url -replace '\?', '.json?' } else { # No query string - just append .json $Url = $Url.TrimEnd('/') + '.json' } } Write-Log -Message "Fetching Reddit data from: $Url" -Level Debug Write-Verbose "Fetching data from: $Url" # Use module version for User-Agent $moduleVersion = $MyInvocation.MyCommand.Module.Version $userAgent = "PSRedditTUI/$moduleVersion" $startTime = Get-Date $rawContent = $null $statusCode = 200 $contentType = "application/json" # Try Invoke-WebRequest first for full response details try { $webResponse = Invoke-WebRequest -Uri $Url -Method Get -UserAgent $userAgent $duration = ((Get-Date) - $startTime).TotalMilliseconds $statusCode = $webResponse.StatusCode $contentType = $webResponse.Headers['Content-Type'] $rawContent = $webResponse.Content Write-Log -Message "Reddit API response: Status=$statusCode, ContentType=$contentType, Size=$($rawContent.Length)bytes, Duration=${duration}ms" -Level Debug } catch [System.ArgumentOutOfRangeException] { # Invoke-WebRequest fails with ArgumentOutOfRangeException on some Reddit responses # This is a known PowerShell Core bug with large Unicode content # Fallback to Invoke-RestMethod which handles these cases better Write-Log -Message "Invoke-WebRequest failed with ArgumentOutOfRangeException, falling back to Invoke-RestMethod" -Level Debug try { $restStartTime = Get-Date $response = Invoke-RestMethod -Uri $Url -Method Get -UserAgent $userAgent $duration = ((Get-Date) - $restStartTime).TotalMilliseconds Write-Log -Message "Reddit API response (via RestMethod): Status=200, Duration=${duration}ms" -Level Debug Write-Log -Message "JSON parsed successfully (via RestMethod fallback)" -Level Debug # Log structure info if ($response -is [array]) { Write-Log -Message "Response is array with $($response.Count) elements" -Level Debug } elseif ($response.data) { $childCount = if ($response.data.children) { $response.data.children.Count } else { 0 } Write-Log -Message "Response has data property with $childCount children" -Level Debug } return $response } catch { Write-Log -Message "Invoke-RestMethod also failed for: $Url" -Level Error -ErrorRecord $_ Write-Error "Failed to fetch Reddit data: $_" return $null } } # Log first portion of response for debugging (truncate if too long) if ($rawContent) { try { $previewLength = [Math]::Min($rawContent.Length, 500) $preview = $rawContent.Substring(0, $previewLength) if ($rawContent.Length -gt 500) { $preview += "... [truncated, total: $($rawContent.Length) chars]" } Write-Log -Message "Response preview: $preview" -Level Debug } catch { Write-Log -Message "Response size: $($rawContent.Length) chars (preview failed)" -Level Debug } # Check if content type indicates JSON if ($contentType -and -not ($contentType -match 'application/json|text/json')) { Write-Log -Message "Warning: Unexpected content type '$contentType' - expected JSON" -Level Warning } # Validate JSON and parse try { $response = $rawContent | ConvertFrom-Json Write-Log -Message "JSON parsed successfully" -Level Debug # Log structure info if ($response -is [array]) { Write-Log -Message "Response is array with $($response.Count) elements" -Level Debug } elseif ($response.data) { $childCount = if ($response.data.children) { $response.data.children.Count } else { 0 } Write-Log -Message "Response has data property with $childCount children" -Level Debug } return $response } catch { Write-Log -Message "Failed to parse JSON response from: $Url" -Level Error -ErrorRecord $_ Write-Log -Message "Invalid JSON content: $preview" -Level Error Write-Error "Response is not valid JSON: $_" return $null } } } catch { $errorContext = "URL: $Url" # Add HTTP response details if available if ($_.Exception.Response) { $errorContext += " | HTTP Status: $($_.Exception.Response.StatusCode) $($_.Exception.Response.StatusDescription)" } # Add specific error type information if ($_.Exception -is [System.Net.WebException]) { $errorContext += " | Network Error: $($_.Exception.Status)" } Write-Log -Message "Failed to fetch Reddit data - $errorContext" -Level Error -ErrorRecord $_ Write-Error "Failed to fetch Reddit data: $_" return $null } } function Get-RedditPosts { <# .SYNOPSIS Gets posts from a subreddit .PARAMETER Subreddit The subreddit name (without r/) .PARAMETER Sort Sort order (hot, new, top, rising) .PARAMETER Time Time filter for 'top' sort (hour, day, week, month, year, all) .EXAMPLE Get-RedditPosts -Subreddit "powershell" -Sort "hot" .EXAMPLE Get-RedditPosts -Subreddit "powershell" -Sort "top" -Time "week" #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidatePattern('^[a-zA-Z0-9_+\-]+$')] [string]$Subreddit, [Parameter(Mandatory = $false)] [ValidateSet('hot', 'new', 'top', 'rising')] [string]$Sort = 'hot', [Parameter(Mandatory = $false)] [ValidateSet('hour', 'day', 'week', 'month', 'year', 'all')] [string]$Time = 'day', [Parameter(Mandatory = $false)] [string]$After ) $isMultireddit = $Subreddit -match '\+' Write-Log -Message "Getting posts from r/$Subreddit (sort: $Sort, time: $Time, after: $After, multireddit: $isMultireddit)" -Level Info $url = "https://www.reddit.com/r/$Subreddit/$Sort" Write-Log -Message "Get-RedditPosts: base URL = $url" -Level Debug # Add time filter for 'top' sort if ($Sort -eq 'top') { $url += "?t=$Time" Write-Log -Message "Get-RedditPosts: appended time filter, URL = $url" -Level Debug } # Append pagination cursor if ($After) { $separator = if ($url -match '\?') { '&' } else { '?' } $url += "${separator}after=$After" Write-Log -Message "Get-RedditPosts: appended pagination cursor '$After', URL = $url" -Level Debug } Write-Log -Message "Get-RedditPosts: calling Get-RedditData with final URL = $url" -Level Debug $data = Get-RedditData -Url $url # Capture pagination cursor $script:AfterCursor = if ($data -and $data.data) { $data.data.after } else { $null } Write-Log -Message "Get-RedditPosts: response received, data null=$($null -eq $data), AfterCursor=$($script:AfterCursor)" -Level Debug if ($data -and $data.data -and $data.data.children) { $postCount = $data.data.children.Count Write-Log -Message "Get-RedditPosts: retrieved $postCount posts from r/$Subreddit (after cursor: $($script:AfterCursor))" -Level Debug $postIndex = 0 return $data.data.children | ForEach-Object { Write-Log -Message "Get-RedditPosts: post[$postIndex] title='$($_.data.title)' post_hint='$($_.data.post_hint)' is_video=$($_.data.is_video) subreddit=$($_.data.subreddit)" -Level Debug $postIndex++ [PSCustomObject]@{ Title = $_.data.title Author = $_.data.author Score = $_.data.score Subreddit = $_.data.subreddit Url = "https://www.reddit.com$($_.data.permalink)" Permalink = $_.data.permalink NumComments = $_.data.num_comments Created = [DateTimeOffset]::FromUnixTimeSeconds($_.data.created_utc).LocalDateTime SelfText = $_.data.selftext IsLink = -not [string]::IsNullOrEmpty($_.data.url) -and $_.data.url -ne $_.data.permalink LinkUrl = $_.data.url PostHint = $_.data.post_hint IsVideo = [bool]$_.data.is_video } } } Write-Log -Message "Get-RedditPosts: no data returned for r/$Subreddit" -Level Warning return @() } function Get-RedditComments { <# .SYNOPSIS Gets comments for a Reddit post .PARAMETER Permalink The permalink of the post (e.g., "/r/powershell/comments/abc123/title/") .PARAMETER Limit Maximum number of comments to retrieve (default: 50) .EXAMPLE Get-RedditComments -Permalink "/r/powershell/comments/abc123/my_post/" #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Permalink, [Parameter(Mandatory = $false)] [int]$Limit = 50 ) Write-Log -Message "Getting comments for: $Permalink" -Level Info $url = "https://www.reddit.com$Permalink" $data = Get-RedditData -Url $url if ($data -and $data.Count -ge 2) { # First element is the post, second element contains comments $commentsData = $data[1] if ($commentsData.data -and $commentsData.data.children) { $comments = @() $commentCount = 0 foreach ($child in $commentsData.data.children) { if ($child.kind -eq 't1' -and $commentCount -lt $Limit) { $comment = ConvertTo-CommentObject -CommentData $child.data -Depth 0 if ($comment) { $comments += $comment $commentCount++ } } } Write-Log -Message "Retrieved $($comments.Count) top-level comments" -Level Debug return $comments } } Write-Log -Message "No comments found for: $Permalink" -Level Debug return @() } function Search-Reddit { <# .SYNOPSIS Searches Reddit for posts .PARAMETER Query The search query .PARAMETER Subreddit Optional subreddit to search within (omit for global search) .PARAMETER Sort Sort order for results (relevance, hot, top, new, comments) .PARAMETER Time Time filter (hour, day, week, month, year, all) .EXAMPLE Search-Reddit -Query "powershell scripts" .EXAMPLE Search-Reddit -Query "automation" -Subreddit "powershell" #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Query, [Parameter(Mandatory = $false)] [string]$Subreddit, [Parameter(Mandatory = $false)] [ValidateSet('relevance', 'hot', 'top', 'new', 'comments')] [string]$Sort = 'relevance', [Parameter(Mandatory = $false)] [ValidateSet('hour', 'day', 'week', 'month', 'year', 'all')] [string]$Time = 'all', [Parameter(Mandatory = $false)] [string]$After ) Write-Log -Message "Searching Reddit: '$Query' (subreddit: $Subreddit, sort: $Sort, time: $Time, after: $After)" -Level Info # URL encode the query $encodedQuery = [System.Web.HttpUtility]::UrlEncode($Query) # Build URL if ($Subreddit) { $url = "https://www.reddit.com/r/$Subreddit/search.json?q=$encodedQuery&restrict_sr=on&sort=$Sort&t=$Time" } else { $url = "https://www.reddit.com/search.json?q=$encodedQuery&sort=$Sort&t=$Time" } # Append pagination cursor if ($After) { $url += "&after=$After" Write-Log -Message "Search-Reddit: appended pagination cursor '$After'" -Level Debug } $ProgressPreference = 'SilentlyContinue' try { Write-Log -Message "Search-Reddit: final URL = $url" -Level Debug # Use module version for User-Agent $moduleVersion = $MyInvocation.MyCommand.Module.Version $userAgent = "PSRedditTUI/$moduleVersion" $startTime = Get-Date $response = Invoke-RestMethod -Uri $url -Method Get -UserAgent $userAgent $duration = ((Get-Date) - $startTime).TotalMilliseconds Write-Log -Message "Search completed in ${duration}ms" -Level Debug # Capture pagination cursor $script:AfterCursor = if ($response -and $response.data) { $response.data.after } else { $null } Write-Log -Message "Search-Reddit: response received, data null=$($null -eq $response), AfterCursor=$($script:AfterCursor)" -Level Debug if ($response -and $response.data -and $response.data.children) { $resultCount = $response.data.children.Count Write-Log -Message "Search-Reddit: returned $resultCount results (after cursor: $($script:AfterCursor))" -Level Info return $response.data.children | ForEach-Object { [PSCustomObject]@{ Title = $_.data.title Author = $_.data.author Score = $_.data.score Subreddit = $_.data.subreddit Url = "https://www.reddit.com$($_.data.permalink)" Permalink = $_.data.permalink NumComments = $_.data.num_comments Created = [DateTimeOffset]::FromUnixTimeSeconds($_.data.created_utc).LocalDateTime SelfText = $_.data.selftext IsLink = -not [string]::IsNullOrEmpty($_.data.url) -and $_.data.url -ne $_.data.permalink LinkUrl = $_.data.url PostHint = $_.data.post_hint IsVideo = [bool]$_.data.is_video } } } return @() } catch { Write-Log -Message "Search failed for query: '$Query' (subreddit: $Subreddit)" -Level Error -ErrorRecord $_ Write-Error "Failed to search Reddit: $_" return @() } } function ConvertTo-CommentObject { <# .SYNOPSIS Converts Reddit comment data to a PowerShell object (recursive for replies) #> param( [Parameter(Mandatory = $true)] $CommentData, [Parameter(Mandatory = $false)] [int]$Depth = 0 ) if (-not $CommentData.body) { return $null } $replies = @() # Process replies if they exist if ($CommentData.replies -and $CommentData.replies.data -and $CommentData.replies.data.children) { foreach ($reply in $CommentData.replies.data.children) { if ($reply.kind -eq 't1') { $replyObj = ConvertTo-CommentObject -CommentData $reply.data -Depth ($Depth + 1) if ($replyObj) { $replies += $replyObj } } } } return [PSCustomObject]@{ Author = $CommentData.author Body = $CommentData.body Score = $CommentData.score Created = [DateTimeOffset]::FromUnixTimeSeconds($CommentData.created_utc).LocalDateTime Depth = $Depth Replies = $replies IsOP = $CommentData.is_submitter } } #endregion #region Favorites Management function Get-Favorites { <# .SYNOPSIS Loads favorites from local storage .DESCRIPTION Loads favorites from the local JSON file. If the file doesn't exist (first launch), it creates the file with a default set of popular subreddits. #> [CmdletBinding()] param() Write-Log -Message "Loading favorites from: $script:FavoritesFile" -Level Debug if (Test-Path $script:FavoritesFile) { try { $loadedFavorites = Get-Content $script:FavoritesFile -Raw | ConvertFrom-Json -NoEnumerate if ($null -eq $loadedFavorites) { $script:Favorites = @() Write-Log -Message "Favorites file was empty" -Level Debug } else { # Always wrap in array to handle single-element arrays $script:Favorites = @($loadedFavorites) Write-Log -Message "Loaded $($script:Favorites.Count) favorites: $($script:Favorites -join ', ')" -Level Info } # Use Write-Output -NoEnumerate to prevent unwrapping Write-Output -NoEnumerate $script:Favorites return } catch { Write-Log -Message "Failed to load favorites from: $script:FavoritesFile" -Level Error -ErrorRecord $_ Write-Warning "Failed to load favorites: $_" $script:Favorites = @() } } else { # First launch - populate with default subreddits Write-Log -Message "Favorites file does not exist - populating with defaults" -Level Info $script:Favorites = $script:DefaultFavorites.Clone() # Save defaults to file try { Save-Favorites Write-Log -Message "Created favorites file with $($script:Favorites.Count) default subreddits: $($script:Favorites -join ', ')" -Level Info Write-Verbose "First launch: Populated favorites with default subreddits" } catch { Write-Log -Message "Failed to save default favorites" -Level Error -ErrorRecord $_ } # Use Write-Output -NoEnumerate to prevent unwrapping Write-Output -NoEnumerate $script:Favorites return } return @() } function Save-Favorites { <# .SYNOPSIS Saves favorites to local storage #> [CmdletBinding()] param() Write-Log -Message "Saving $($script:Favorites.Count) favorites to: $script:FavoritesFile" -Level Debug try { if ($script:Favorites.Count -eq 0) { # Save empty array explicitly '[]' | Set-Content $script:FavoritesFile } else { $script:Favorites | ConvertTo-Json -Depth 10 | Set-Content $script:FavoritesFile } Write-Log -Message "Favorites saved successfully" -Level Info Write-Verbose "Favorites saved to $script:FavoritesFile" } catch { Write-Log -Message "Failed to save favorites to: $script:FavoritesFile" -Level Error -ErrorRecord $_ Write-Error "Failed to save favorites: $_" } } function Add-Favorite { <# .SYNOPSIS Adds a subreddit to favorites .PARAMETER Subreddit The subreddit name to add .PARAMETER PassThru Return an object representing the added favorite #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Subreddit, [Parameter()] [switch]$PassThru ) Write-Log -Message "Add-Favorite called with: $Subreddit" -Level Debug # Strip r/ prefix if present and normalize to lowercase $normalizedSubreddit = $Subreddit.ToLower() -replace '^r/', '' # Check if already exists (case-insensitive) $exists = $script:Favorites | Where-Object { $_.ToLower() -eq $normalizedSubreddit } if (-not $exists) { $script:Favorites += $normalizedSubreddit Save-Favorites Write-Log -Message "Added '$normalizedSubreddit' to favorites" -Level Info if ($PassThru) { [PSCustomObject]@{ Subreddit = $normalizedSubreddit Action = 'Added' Timestamp = Get-Date } } } else { Write-Log -Message "'$normalizedSubreddit' already exists in favorites" -Level Debug if ($PassThru) { [PSCustomObject]@{ Subreddit = $normalizedSubreddit Action = 'AlreadyExists' Timestamp = Get-Date } } } } function Remove-Favorite { <# .SYNOPSIS Removes a subreddit from favorites .PARAMETER Subreddit The subreddit name to remove .PARAMETER PassThru Return an object representing the removed favorite #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Subreddit, [Parameter()] [switch]$PassThru ) Write-Log -Message "Remove-Favorite called with: $Subreddit" -Level Debug # Strip r/ prefix if present and normalize to lowercase $normalizedSubreddit = $Subreddit.ToLower() -replace '^r/', '' # Check if exists (case-insensitive) $exists = $script:Favorites | Where-Object { $_.ToLower() -eq $normalizedSubreddit } if ($exists) { # Ensure we always get an array, even if empty $filtered = @($script:Favorites | Where-Object { $_.ToLower() -ne $normalizedSubreddit }) $script:Favorites = $filtered Save-Favorites Write-Log -Message "Removed '$normalizedSubreddit' from favorites" -Level Info if ($PassThru) { [PSCustomObject]@{ Subreddit = $normalizedSubreddit Action = 'Removed' Timestamp = Get-Date } } } else { Write-Log -Message "'$normalizedSubreddit' not found in favorites" -Level Debug if ($PassThru) { [PSCustomObject]@{ Subreddit = $normalizedSubreddit Action = 'NotFound' Timestamp = Get-Date } } } } #endregion #region Helper Functions #endregion #region Terminal UI function Show-RedditTUI { <# .SYNOPSIS Launches the Terminal UI for browsing Reddit .DESCRIPTION Opens an interactive Terminal UI using Terminal.Gui for browsing Reddit with emoji icons and a favorites sidebar. Uses the default ConsoleDriver with full emoji support (⬆ 💬 📍 👤 📝 🔗) for best compatibility. .PARAMETER InitialSubreddit The subreddit to load initially (default: "popular") .EXAMPLE Show-RedditTUI Launches the TUI with the popular subreddit .EXAMPLE Show-RedditTUI -InitialSubreddit "powershell" Launches the TUI with the PowerShell subreddit #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$InitialSubreddit = "popular" ) $moduleVersion = (Get-Module PSRedditTUI).Version Write-Log -Message "Show-RedditTUI starting with initial subreddit: $InitialSubreddit" -Level Info Write-Log -Message "Debug log file: $script:LogFile" -Level Info Write-Log -Message "PowerShell version: $($PSVersionTable.PSVersion) OS: $($PSVersionTable.OS)" -Level Debug Write-Verbose "PSRedditTUI debug log: $script:LogFile" # Check if Terminal.Gui is available try { $null = [Terminal.Gui.Application] Write-Log -Message "Terminal.Gui is available" -Level Debug } catch { Write-Log -Message "Terminal.Gui is not available - run Install-PSRedditTUITerminalGui" -Level Error -ErrorRecord $_ Write-Error "Terminal.Gui is not available. Run 'Install-PSRedditTUITerminalGui' to install the dependency, or manually install the Terminal.Gui .NET assembly." return } # Load favorites Get-Favorites | Out-Null # Initialize Terminal.Gui with default driver # Check if Application is already initialized and shut it down first if ($null -ne [Terminal.Gui.Application]::Driver) { Write-Log -Message "Application already initialized, shutting down to reinitialize" -Level Debug [Terminal.Gui.Application]::Shutdown() } # Always use emojis - they work fine in modern terminals without NetDriver # NetDriver causes freezing issues, so we use the default ConsoleDriver instead $script:UsingSystemConsole = $true Write-Log -Message "Initializing Terminal.Gui Application with default ConsoleDriver" -Level Debug [Terminal.Gui.Application]::Init() Write-Log -Message "Terminal.Gui initialized successfully with emoji support" -Level Info try { # Create main window $top = [Terminal.Gui.Application]::Top # Create main container $win = [Terminal.Gui.Window]::new("PSRedditTUI - Reddit Terminal Browser") $win.X = 0 $win.Y = 0 $win.Width = [Terminal.Gui.Dim]::Fill() $win.Height = [Terminal.Gui.Dim]::Fill() # Create menu bar $menu = [Terminal.Gui.MenuBar]::new(@( [Terminal.Gui.MenuBarItem]::new("_File", @( [Terminal.Gui.MenuItem]::new("_Quit", "Exit application", { [Terminal.Gui.Application]::RequestStop() }) )), [Terminal.Gui.MenuBarItem]::new("_Help", @( [Terminal.Gui.MenuItem]::new("_About", "About PSRedditTUI", { [Terminal.Gui.MessageBox]::Query("About", "PSRedditTUI v$moduleVersion`nA PowerShell Reddit Terminal Browser`nPress ESC to close", @("OK")) }) )) )) $top.Add($menu) # Create favorites sidebar (left side) $favoritesFrame = [Terminal.Gui.FrameView]::new() $favoritesFrame.Title = "Favorites" $favoritesFrame.X = 0 $favoritesFrame.Y = 1 $favoritesFrame.Width = 25 $favoritesFrame.Height = [Terminal.Gui.Dim]::Fill() $favoritesList = [Terminal.Gui.ListView]::new() $favoritesList.X = 0 $favoritesList.Y = 0 $favoritesList.Width = [Terminal.Gui.Dim]::Fill() $favoritesList.Height = [Terminal.Gui.Dim]::Fill(2) # Populate favorites list $favoritesSource = [System.Collections.Generic.List[string]]::new() foreach ($fav in $script:Favorites) { $favoritesSource.Add("r/$fav") } $favoritesList.SetSource($favoritesSource) $favoritesFrame.Add($favoritesList) # Add favorite buttons $addFavBtn = [Terminal.Gui.Button]::new() $addFavBtn.Text = "Add" $addFavBtn.X = 0 $addFavBtn.Y = [Terminal.Gui.Pos]::AnchorEnd(1) $removeFavBtn = [Terminal.Gui.Button]::new() $removeFavBtn.Text = "Remove" $removeFavBtn.X = [Terminal.Gui.Pos]::Right($addFavBtn) + 1 $removeFavBtn.Y = [Terminal.Gui.Pos]::AnchorEnd(1) $favoritesFrame.Add($addFavBtn) $favoritesFrame.Add($removeFavBtn) # Create main content area (right side) $contentFrame = [Terminal.Gui.FrameView]::new() $contentFrame.Title = "r/$InitialSubreddit" $contentFrame.X = 25 $contentFrame.Y = 1 $contentFrame.Width = [Terminal.Gui.Dim]::Fill() $contentFrame.Height = [Terminal.Gui.Dim]::Fill() # Subreddit input (Row 0) $subredditLabel = [Terminal.Gui.Label]::new() $subredditLabel.Text = "Subreddit:" $subredditLabel.X = 0 $subredditLabel.Y = 0 $subredditInput = [Terminal.Gui.TextField]::new() $subredditInput.Text = $InitialSubreddit $subredditInput.X = [Terminal.Gui.Pos]::Right($subredditLabel) + 1 $subredditInput.Y = 0 $subredditInput.Width = 20 $loadBtn = [Terminal.Gui.Button]::new() $loadBtn.Text = "Load" $loadBtn.X = [Terminal.Gui.Pos]::Right($subredditInput) + 1 $loadBtn.Y = 0 $contentFrame.Add($subredditLabel) $contentFrame.Add($subredditInput) $contentFrame.Add($loadBtn) # Sort and Time Filter (Row 1) $sortLabel = [Terminal.Gui.Label]::new() $sortLabel.Text = "Sort:" $sortLabel.X = 0 $sortLabel.Y = 1 # Sort options $sortOptions = [System.Collections.Generic.List[string]]::new() @("hot", "new", "top", "rising") | ForEach-Object { $sortOptions.Add($_) } $sortCombo = [Terminal.Gui.ComboBox]::new() $sortCombo.X = [Terminal.Gui.Pos]::Right($sortLabel) + 1 $sortCombo.Y = 1 $sortCombo.Width = 10 $sortCombo.Height = 5 $sortCombo.SetSource($sortOptions) $sortCombo.SelectedItem = 0 # Default to "hot" # Time filter label $timeLabel = [Terminal.Gui.Label]::new() $timeLabel.Text = "Time:" $timeLabel.X = [Terminal.Gui.Pos]::Right($sortCombo) + 2 $timeLabel.Y = 1 $timeLabel.Visible = $false # Only visible when sort=top # Time filter options $timeOptions = [System.Collections.Generic.List[string]]::new() @("day", "week", "month", "year", "all") | ForEach-Object { $timeOptions.Add($_) } $timeCombo = [Terminal.Gui.ComboBox]::new() $timeCombo.X = [Terminal.Gui.Pos]::Right($timeLabel) + 1 $timeCombo.Y = 1 $timeCombo.Width = 10 $timeCombo.Height = 5 $timeCombo.SetSource($timeOptions) $timeCombo.SelectedItem = 0 # Default to "day" $timeCombo.Visible = $false # Only visible when sort=top # Track current sort/time $script:CurrentSort = "hot" $script:CurrentTime = "day" # Sort combo selection changed $sortCombo.add_SelectedItemChanged({ Write-Log -Message "TUI: EVENT - Sort combo SelectedItemChanged triggered" -Level Debug try { if ($null -eq $sortCombo) { Write-Log -Message "TUI: EVENT - sortCombo is null, returning" -Level Warning return } $selectedIdx = $sortCombo.SelectedItem Write-Log -Message "TUI: EVENT - Sort selected index: $selectedIdx" -Level Debug if ($selectedIdx -ge 0 -and $selectedIdx -lt $sortOptions.Count) { $script:CurrentSort = $sortOptions[$selectedIdx] Write-Log -Message "TUI: Sort changed to: $($script:CurrentSort)" -Level Debug # Show/hide time filter based on sort type $showTime = ($script:CurrentSort -eq "top") Write-Log -Message "TUI: EVENT - Setting time filter visibility: $showTime" -Level Debug if ($null -ne $timeLabel) { $timeLabel.Visible = $showTime Write-Log -Message "TUI: EVENT - timeLabel.Visible set to $showTime" -Level Debug } else { Write-Log -Message "TUI: EVENT - timeLabel is null" -Level Warning } if ($null -ne $timeCombo) { $timeCombo.Visible = $showTime Write-Log -Message "TUI: EVENT - timeCombo.Visible set to $showTime" -Level Debug } else { Write-Log -Message "TUI: EVENT - timeCombo is null" -Level Warning } } Write-Log -Message "TUI: EVENT - Sort combo handler completed successfully" -Level Debug } catch { Write-Log -Message "TUI: EVENT - Failed in sort combo handler" -Level Error -ErrorRecord $_ } }) # Time combo selection changed $timeCombo.add_SelectedItemChanged({ try { $selectedIdx = $timeCombo.SelectedItem if ($selectedIdx -ge 0 -and $selectedIdx -lt $timeOptions.Count) { $script:CurrentTime = $timeOptions[$selectedIdx] Write-Log -Message "TUI: Time filter changed to: $($script:CurrentTime)" -Level Debug } } catch { Write-Log -Message "TUI: Failed to change time filter" -Level Error -ErrorRecord $_ } }) $contentFrame.Add($sortLabel) $contentFrame.Add($sortCombo) $contentFrame.Add($timeLabel) $contentFrame.Add($timeCombo) # Search row (Row 2) $searchLabel = [Terminal.Gui.Label]::new() $searchLabel.Text = "Search:" $searchLabel.X = 0 $searchLabel.Y = 2 $searchInput = [Terminal.Gui.TextField]::new() $searchInput.X = [Terminal.Gui.Pos]::Right($searchLabel) + 1 $searchInput.Y = 2 $searchInput.Width = 30 $searchBtn = [Terminal.Gui.Button]::new() $searchBtn.Text = "Search" $searchBtn.X = [Terminal.Gui.Pos]::Right($searchInput) + 1 $searchBtn.Y = 2 $searchGlobalCheck = [Terminal.Gui.CheckBox]::new() $searchGlobalCheck.Text = "All Reddit" $searchGlobalCheck.X = [Terminal.Gui.Pos]::Right($searchBtn) + 1 $searchGlobalCheck.Y = 2 $contentFrame.Add($searchLabel) $contentFrame.Add($searchInput) $contentFrame.Add($searchBtn) $contentFrame.Add($searchGlobalCheck) # Posts list view (moved to Y=4 for search row) Write-Log -Message "TUI: Creating postsListView at Y=4, Height=Fill(1) to leave room for Load More button" -Level Debug $postsListView = [Terminal.Gui.ListView]::new() $postsListView.X = 0 $postsListView.Y = 4 $postsListView.Width = [Terminal.Gui.Dim]::Fill() $postsListView.Height = [Terminal.Gui.Dim]::Fill(1) $contentFrame.Add($postsListView) # Load More button (hidden by default) Write-Log -Message "TUI: Creating Load More button (hidden by default)" -Level Debug $loadMoreBtn = [Terminal.Gui.Button]::new() $loadMoreBtn.Text = "Load More..." $loadMoreBtn.X = [Terminal.Gui.Pos]::Center() $loadMoreBtn.Y = [Terminal.Gui.Pos]::AnchorEnd(1) $loadMoreBtn.Visible = $false $contentFrame.Add($loadMoreBtn) Write-Log -Message "TUI: Load More button added to contentFrame" -Level Debug # Store current posts for click handling $script:CurrentPosts = @() $script:IsSearchResults = $false # Function to format comments with indentation $formatComments = { param($comments, $maxDepth = 3) $lines = [System.Collections.Generic.List[string]]::new() $processComment = { param($comment, $currentDepth) if ($currentDepth -gt $maxDepth) { return } $indent = " " * $comment.Depth $opTag = if ($comment.IsOP) { " [OP]" } else { "" } $scoreStr = if ($comment.Score -ge 0) { "+$($comment.Score)" } else { "$($comment.Score)" } # Author line $lines.Add("${indent}[$scoreStr] u/$($comment.Author)$opTag") # Body - wrap long lines $bodyLines = $comment.Body -split "`n" foreach ($bodyLine in $bodyLines) { # Wrap at ~70 chars accounting for indent $maxLen = 70 - ($comment.Depth * 2) if ($maxLen -lt 30) { $maxLen = 30 } if ($bodyLine.Length -gt $maxLen) { $words = $bodyLine -split ' ' $currentLine = "$indent " foreach ($word in $words) { if (($currentLine.Length + $word.Length + 1) -gt ($maxLen + $indent.Length + 2)) { $lines.Add($currentLine.TrimEnd()) $currentLine = "$indent $word " } else { $currentLine += "$word " } } if ($currentLine.Trim().Length -gt 0) { $lines.Add($currentLine.TrimEnd()) } } else { $lines.Add("$indent $bodyLine") } } $lines.Add("") # Blank line after comment # Process replies foreach ($reply in $comment.Replies) { & $processComment $reply ($currentDepth + 1) } } foreach ($comment in $comments) { & $processComment $comment 0 } return $lines } # Function to show post details dialog $showPostDetail = { param($post) # Copy parent-scope refs into locals so .GetNewClosure() captures them $listView = $postsListView $logFile = $script:LogFile Write-Log -Message "TUI: Opening post detail for: $($post.Title)" -Level Info # Create dialog # Use Window instead of Dialog to avoid ESC closing the entire app $dialog = [Terminal.Gui.Window]::new("Post Details (ESC to close)") $dialog.X = [Terminal.Gui.Pos]::Center() $dialog.Y = [Terminal.Gui.Pos]::Center() $dialog.Width = [Terminal.Gui.Dim]::Percent(90) $dialog.Height = [Terminal.Gui.Dim]::Percent(90) $dialog.Modal = $true # Post header info # Use emojis when NetDriver (UseSystemConsole) is enabled for better Unicode support $locationIcon = if ($script:UsingSystemConsole) { "📍" } else { "@" } $userIcon = if ($script:UsingSystemConsole) { "👤" } else { "u" } $upvoteIcon = if ($script:UsingSystemConsole) { "⬆" } else { "^" } $commentIcon = if ($script:UsingSystemConsole) { "💬" } else { "#" } $headerText = "$locationIcon r/$($post.Subreddit) | $userIcon u/$($post.Author) | $upvoteIcon $($post.Score) | $commentIcon $($post.NumComments)" $headerLabel = [Terminal.Gui.Label]::new() $headerLabel.Text = $headerText $headerLabel.X = 1 $headerLabel.Y = 0 $headerLabel.Width = [Terminal.Gui.Dim]::Fill() $dialog.Add($headerLabel) # Title $titleLabel = [Terminal.Gui.Label]::new() $titleLabel.Text = $post.Title $titleLabel.X = 1 $titleLabel.Y = 1 $titleLabel.Width = [Terminal.Gui.Dim]::Fill() $dialog.Add($titleLabel) # Separator $sepLabel = [Terminal.Gui.Label]::new() $sepLabel.Text = ("-" * 80) $sepLabel.X = 1 $sepLabel.Y = 2 $dialog.Add($sepLabel) # Content area - TextView for scrolling $contentView = [Terminal.Gui.TextView]::new() $contentView.X = 1 $contentView.Y = 3 $contentView.Width = [Terminal.Gui.Dim]::Fill(1) $contentView.Height = [Terminal.Gui.Dim]::Fill(2) $contentView.ReadOnly = $true $contentView.WordWrap = $true # Build content $contentLines = [System.Collections.Generic.List[string]]::new() # Add self text if present if (-not [string]::IsNullOrWhiteSpace($post.SelfText)) { $contentLines.Add("--- POST CONTENT ---") $contentLines.Add("") foreach ($line in ($post.SelfText -split "`n")) { $contentLines.Add($line) } $contentLines.Add("") $contentLines.Add("--- COMMENTS ---") $contentLines.Add("") } else { if ($post.IsLink) { $contentLines.Add("Link: $($post.LinkUrl)") $contentLines.Add("") } $contentLines.Add("--- COMMENTS ---") $contentLines.Add("") } # Show loading message $contentLines.Add("Loading comments...") $contentView.Text = ($contentLines -join "`n") $dialog.Add($contentView) # Open in Browser button $openBtn = [Terminal.Gui.Button]::new() $openBtn.Text = "Open in Browser (O)" $openBtn.X = [Terminal.Gui.Pos]::Center() - 15 $openBtn.Y = [Terminal.Gui.Pos]::AnchorEnd(1) $openBtn.add_Clicked({ $urlToOpen = if ($post.IsLink) { $post.LinkUrl } else { $post.Url } Start-Process $urlToOpen }.GetNewClosure()) $dialog.Add($openBtn) # Close button $closeBtn = [Terminal.Gui.Button]::new() $closeBtn.Text = "Close (ESC)" $closeBtn.X = [Terminal.Gui.Pos]::Center() + 8 $closeBtn.Y = [Terminal.Gui.Pos]::AnchorEnd(1) $closeBtn.add_Clicked({ $top = [Terminal.Gui.Application]::Top $top.Remove($dialog) $listView.SetFocus() }.GetNewClosure()) $dialog.Add($closeBtn) # Handle O key to open in browser and ESC to close # Terminal.Gui v1.x passes KeyEventEventArgs; key is at .KeyEvent.Key $dialog.add_KeyPress({ param($keyEvent) if ($null -eq $keyEvent) { return } # Resolve the Key from either .KeyEvent.Key or .Key depending on TG version $k = $null if ($null -ne $keyEvent.KeyEvent) { $k = $keyEvent.KeyEvent.Key } elseif ($null -ne $keyEvent.Key) { $k = $keyEvent.Key } if ($null -eq $k) { return } $key = $k.ToString() if ($key -eq 'Esc' -or $key -eq 'Escape') { $top = [Terminal.Gui.Application]::Top $top.Remove($dialog) $listView.SetFocus() $keyEvent.Handled = $true } elseif ($key -eq 'O' -or $key -eq 'o') { $urlToOpen = if ($post.IsLink) { $post.LinkUrl } else { $post.Url } Start-Process $urlToOpen $keyEvent.Handled = $true } }.GetNewClosure()) # Load comments in background after dialog shows $loadCommentsAction = { try { Write-Log -Message "TUI: Fetching comments for permalink: $($post.Permalink)" -Level Debug $comments = Get-RedditComments -Permalink $post.Permalink -Limit 30 # Rebuild content with comments $newContent = [System.Collections.Generic.List[string]]::new() if (-not [string]::IsNullOrWhiteSpace($post.SelfText)) { $newContent.Add("--- POST CONTENT ---") $newContent.Add("") foreach ($line in ($post.SelfText -split "`n")) { $newContent.Add($line) } $newContent.Add("") $newContent.Add("--- COMMENTS ($($comments.Count)) ---") $newContent.Add("") } else { if ($post.IsLink) { $newContent.Add("Link: $($post.LinkUrl)") $newContent.Add("") } $newContent.Add("--- COMMENTS ($($comments.Count)) ---") $newContent.Add("") } if ($comments.Count -gt 0) { $formattedComments = & $formatComments $comments 3 foreach ($line in $formattedComments) { $newContent.Add($line) } } else { $newContent.Add("No comments yet.") } $contentView.Text = ($newContent -join "`n") Write-Log -Message "TUI: Displayed $($comments.Count) comments" -Level Debug } catch { $errorDetails = "permalink=$($post.Permalink)" $errorDetails += " | Exception: $($_.Exception.GetType().Name)" if ($_.Exception.Message) { $errorDetails += " | Message: $($_.Exception.Message)" } if ($_.Exception.InnerException) { $errorDetails += " | Inner: $($_.Exception.InnerException.Message)" } Write-Log -Message "TUI: Failed to load comments - $errorDetails" -Level Error -ErrorRecord $_ $contentView.Text = "Failed to load comments: $($_.Exception.Message)`n`nCheck the log file for more details: Get-PSRedditTUILog" } } # Fetch comments before showing dialog & $loadCommentsAction # Show dialog - Don't use Application.Run() from within event handler as it creates nested event loop # Instead, add to top and set focus, letting main loop handle it Write-Log -Message "TUI: About to show post detail dialog" -Level Debug Write-Log -Message "TUI: Dialog object null check: $($null -ne $dialog)" -Level Debug try { $top = [Terminal.Gui.Application]::Top Write-Log -Message "TUI: top null check: $($null -ne $top)" -Level Debug $top.Add($dialog) Write-Log -Message "TUI: Dialog added to top" -Level Debug $dialog.SetFocus() Write-Log -Message "TUI: Focus set on dialog" -Level Debug } catch { Write-Log -Message "TUI: CRASH - Failed to show post detail dialog" -Level Error -ErrorRecord $_ throw } } # Function to get post type tag from post_hint/is_video # Uses short ASCII tags that render reliably in Terminal.Gui $getTypeTag = { param($post) if ($post.IsVideo) { return '[vid]' } switch ($post.PostHint) { 'image' { return '[img]' } 'hosted:video' { return '[vid]' } 'rich:video' { return '[vid]' } 'link' { return '[lnk]' } 'self' { return '[txt]' } default { return '[txt]' } } } # Function to load posts $loadPosts = { param($subreddit) $script:IsSearchResults = $false $script:AfterCursor = $null $sort = $script:CurrentSort $time = $script:CurrentTime Write-Log -Message "TUI: Loading posts for r/$subreddit (sort: $sort, time: $time)" -Level Info try { Write-Log -Message "TUI: loadPosts started for r/$subreddit" -Level Debug $sortInfo = if ($sort -eq 'top') { "$sort/$time" } else { $sort } $contentFrame.Title = "r/$subreddit/$sortInfo (Loading...)" Write-Log -Message "TUI: Title updated to show Loading message" -Level Debug # Don't call Application.Refresh() here - it causes deadlocks with NetDriver in event handler context # The UI will update automatically on the next event loop iteration $script:CurrentPosts = Get-RedditPosts -Subreddit $subreddit -Sort $sort -Time $time -ErrorAction Stop Write-Log -Message "TUI: API call completed, building posts list" -Level Debug $postsList = [System.Collections.Generic.List[string]]::new() foreach ($post in $script:CurrentPosts) { $tag = & $getTypeTag $post $score = $post.Score.ToString().PadLeft(6) $comments = $post.NumComments.ToString().PadLeft(4) $title = $post.Title if ($title.Length -gt 75) { $title = $title.Substring(0, 72) + "..." } $postsList.Add("$tag $score ^ $comments # | $title") } Write-Log -Message "TUI: About to call SetSource with $($postsList.Count) items" -Level Debug $postsListView.SetSource($postsList) Write-Log -Message "TUI: SetSource completed successfully" -Level Debug $contentFrame.Title = "r/$subreddit/$sortInfo - $($script:CurrentPosts.Count) posts (Enter=view, O=open)" Write-Log -Message "TUI: Displayed $($script:CurrentPosts.Count) posts for r/$subreddit" -Level Debug # Show/hide Load More button based on pagination cursor $hasMore = [bool]$script:AfterCursor Write-Log -Message "TUI: loadPosts: AfterCursor='$($script:AfterCursor)' hasMore=$hasMore, showing Load More button=$hasMore" -Level Debug $loadMoreBtn.Visible = $hasMore # Set focus to posts list to ensure it's interactive Write-Log -Message "TUI: About to call SetFocus on posts list" -Level Debug $postsListView.SetFocus() Write-Log -Message "TUI: SetFocus completed, loadPosts finished successfully" -Level Debug } catch { $errorDetails = "subreddit=r/$subreddit, sort=$sort, time=$time" $errorDetails += " | Exception: $($_.Exception.GetType().Name)" if ($_.Exception.Message) { $errorDetails += " | Message: $($_.Exception.Message)" } Write-Log -Message "TUI: Failed to load posts - $errorDetails" -Level Error -ErrorRecord $_ $userMessage = "Failed to load subreddit: $($_.Exception.Message)" [Terminal.Gui.MessageBox]::ErrorQuery("Error", $userMessage, @("OK")) $contentFrame.Title = "r/$subreddit (Error)" } } # Function to search posts $searchPosts = { param($query, $subreddit, $globalSearch) $script:IsSearchResults = $true $script:AfterCursor = $null $script:LastSearchQuery = $query $script:LastSearchSubreddit = $subreddit $script:LastSearchGlobal = $globalSearch Write-Log -Message "TUI: searchPosts: query='$query' global=$globalSearch subreddit='$subreddit'" -Level Info Write-Log -Message "TUI: searchPosts: reset AfterCursor to null, stored search params for pagination replay" -Level Debug try { $searchScope = if ($globalSearch) { "All Reddit" } else { "r/$subreddit" } $contentFrame.Title = "Searching $searchScope for '$query'..." [Terminal.Gui.Application]::Refresh() if ($globalSearch) { $script:CurrentPosts = Search-Reddit -Query $query -ErrorAction Stop } else { $script:CurrentPosts = Search-Reddit -Query $query -Subreddit $subreddit -ErrorAction Stop } $postsList = [System.Collections.Generic.List[string]]::new() foreach ($post in $script:CurrentPosts) { $tag = & $getTypeTag $post $score = $post.Score.ToString().PadLeft(6) $comments = $post.NumComments.ToString().PadLeft(4) $sub = $post.Subreddit $title = $post.Title if ($title.Length -gt 55) { $title = $title.Substring(0, 52) + "..." } $postsList.Add("$tag $score ^ $comments # | r/$sub | $title") } $postsListView.SetSource($postsList) $resultScope = if ($globalSearch) { "Reddit" } else { "r/$subreddit" } $contentFrame.Title = "Search '$query' in $resultScope - $($script:CurrentPosts.Count) results (Enter=view, O=open)" Write-Log -Message "TUI: Search returned $($script:CurrentPosts.Count) results" -Level Debug # Show/hide Load More button based on pagination cursor $hasMore = [bool]$script:AfterCursor Write-Log -Message "TUI: searchPosts: AfterCursor='$($script:AfterCursor)' hasMore=$hasMore, showing Load More button=$hasMore" -Level Debug $loadMoreBtn.Visible = $hasMore } catch { $errorDetails = "query='$query', global=$globalSearch" if (-not $globalSearch) { $errorDetails += ", subreddit=$subreddit" } $errorDetails += " | Exception: $($_.Exception.GetType().Name)" if ($_.Exception.Message) { $errorDetails += " | Message: $($_.Exception.Message)" } Write-Log -Message "TUI: Search failed - $errorDetails" -Level Error -ErrorRecord $_ $userMessage = "Search failed: $($_.Exception.Message)" [Terminal.Gui.MessageBox]::ErrorQuery("Error", $userMessage, @("OK")) $contentFrame.Title = "Search Error" } } # Search button click event $searchBtn.add_Clicked({ try { $query = $searchInput.Text.ToString().Trim() if ($query) { $subreddit = $subredditInput.Text.ToString().Trim() $globalSearch = $searchGlobalCheck.Checked Write-Log -Message "TUI: Search button clicked - query: '$query', global: $globalSearch" -Level Debug & $searchPosts $query $subreddit $globalSearch } else { [Terminal.Gui.MessageBox]::Query("Search", "Please enter a search query", @("OK")) } } catch { Write-Log -Message "TUI: Failed to search" -Level Error -ErrorRecord $_ [Terminal.Gui.MessageBox]::ErrorQuery("Error", "Failed to search: $_", @("OK")) } }) # Load More button click event $loadMoreBtn.add_Clicked({ try { $cursor = $script:AfterCursor Write-Log -Message "TUI: LoadMore: clicked, cursor='$cursor' IsSearchResults=$($script:IsSearchResults)" -Level Debug if (-not $cursor) { Write-Log -Message "TUI: LoadMore: no cursor available, returning early" -Level Debug return } Write-Log -Message "TUI: LoadMore: fetching next page with cursor='$cursor'" -Level Info if ($script:IsSearchResults) { # Paginate search results $q = $script:LastSearchQuery $sub = $script:LastSearchSubreddit $global = $script:LastSearchGlobal Write-Log -Message "TUI: LoadMore: search pagination - query='$q' subreddit='$sub' global=$global" -Level Debug if ($global) { $newPosts = Search-Reddit -Query $q -After $cursor -ErrorAction Stop } else { $newPosts = Search-Reddit -Query $q -Subreddit $sub -After $cursor -ErrorAction Stop } } else { # Paginate subreddit posts $sub = $subredditInput.Text.ToString().Trim() $sort = $script:CurrentSort $time = $script:CurrentTime Write-Log -Message "TUI: LoadMore: subreddit pagination - sub='$sub' sort=$sort time=$time" -Level Debug $newPosts = Get-RedditPosts -Subreddit $sub -Sort $sort -Time $time -After $cursor -ErrorAction Stop } $newCount = if ($newPosts) { @($newPosts).Count } else { 0 } Write-Log -Message "TUI: LoadMore: API returned $newCount new posts, new AfterCursor='$($script:AfterCursor)'" -Level Debug if ($newPosts -and $newCount -gt 0) { $prevCount = $script:CurrentPosts.Count $script:CurrentPosts = @($script:CurrentPosts) + @($newPosts) Write-Log -Message "TUI: LoadMore: appended $newCount posts ($prevCount -> $($script:CurrentPosts.Count) total)" -Level Debug # Rebuild the list view $postsList = [System.Collections.Generic.List[string]]::new() foreach ($post in $script:CurrentPosts) { $tag = & $getTypeTag $post $score = $post.Score.ToString().PadLeft(6) $comments = $post.NumComments.ToString().PadLeft(4) if ($script:IsSearchResults) { $sub = $post.Subreddit $title = $post.Title if ($title.Length -gt 55) { $title = $title.Substring(0, 52) + "..." } $postsList.Add("$tag $score ^ $comments # | r/$sub | $title") } else { $title = $post.Title if ($title.Length -gt 75) { $title = $title.Substring(0, 72) + "..." } $postsList.Add("$tag $score ^ $comments # | $title") } } Write-Log -Message "TUI: LoadMore: rebuilding ListView with $($postsList.Count) items" -Level Debug $postsListView.SetSource($postsList) Write-Log -Message "TUI: LoadMore: ListView updated" -Level Debug } else { Write-Log -Message "TUI: LoadMore: no new posts returned" -Level Debug } # Show/hide button based on new cursor $hasMore = [bool]$script:AfterCursor Write-Log -Message "TUI: LoadMore: done, new AfterCursor='$($script:AfterCursor)' hasMore=$hasMore" -Level Debug $loadMoreBtn.Visible = $hasMore } catch { Write-Log -Message "TUI: Load More failed" -Level Error -ErrorRecord $_ [Terminal.Gui.MessageBox]::ErrorQuery("Error", "Failed to load more: $($_.Exception.Message)", @("OK")) } }) # Post selection event - open post detail $postsListView.add_OpenSelectedItem({ Write-Log -Message "TUI: EVENT - Posts list OpenSelectedItem triggered" -Level Debug try { $selected = $postsListView.SelectedItem Write-Log -Message "TUI: EVENT - Selected post index: $selected, Total posts: $($script:CurrentPosts.Count)" -Level Debug if ($selected -ge 0 -and $selected -lt $script:CurrentPosts.Count) { $selectedPost = $script:CurrentPosts[$selected] Write-Log -Message "TUI: EVENT - Opening post detail for: $($selectedPost.Title)" -Level Debug Write-Log -Message "TUI: EVENT - Calling showPostDetail scriptblock" -Level Debug & $showPostDetail $selectedPost Write-Log -Message "TUI: EVENT - showPostDetail completed" -Level Debug } else { Write-Log -Message "TUI: EVENT - Invalid selection index: $selected" -Level Warning } } catch { Write-Log -Message "TUI: EVENT - Failed to show post detail" -Level Error -ErrorRecord $_ Write-Log -Message "TUI: EVENT - Showing error dialog" -Level Debug [Terminal.Gui.MessageBox]::ErrorQuery("Error", "Failed to open post: $_", @("OK")) Write-Log -Message "TUI: EVENT - Error dialog shown" -Level Debug } }) # Key press handler for posts list (O to open in browser) $postsListView.add_KeyPress({ param($keyEvent) if ($null -eq $keyEvent) { return } $k = $null if ($null -ne $keyEvent.KeyEvent) { $k = $keyEvent.KeyEvent.Key } elseif ($null -ne $keyEvent.Key) { $k = $keyEvent.Key } if ($null -eq $k) { return } $key = $k.ToString() if ($key -eq 'O' -or $key -eq 'o') { $selected = $postsListView.SelectedItem if ($selected -ge 0 -and $selected -lt $script:CurrentPosts.Count) { $selectedPost = $script:CurrentPosts[$selected] $urlToOpen = if ($selectedPost.IsLink) { $selectedPost.LinkUrl } else { $selectedPost.Url } Start-Process $urlToOpen $keyEvent.Handled = $true } } }.GetNewClosure()) # Load button click event $loadBtn.add_Clicked({ try { $sub = $subredditInput.Text.ToString().Trim() if ($sub) { Write-Log -Message "TUI: Load button clicked for: $sub" -Level Debug & $loadPosts $sub } } catch { Write-Log -Message "TUI: Failed to load subreddit" -Level Error -ErrorRecord $_ [Terminal.Gui.MessageBox]::ErrorQuery("Error", "Failed to load subreddit: $_", @("OK")) } }) # Favorites list selection event $favoritesList.add_OpenSelectedItem({ try { $selected = $favoritesList.SelectedItem if ($selected -ge 0 -and $selected -lt $favoritesSource.Count) { $fav = $favoritesSource[$selected].ToString().Replace("r/", "") Write-Log -Message "TUI: Favorite selected: $fav" -Level Debug $subredditInput.Text = $fav & $loadPosts $fav Write-Log -Message "TUI: Favorite selection complete" -Level Debug } } catch { Write-Log -Message "TUI: Failed to load favorite" -Level Error -ErrorRecord $_ [Terminal.Gui.MessageBox]::ErrorQuery("Error", "Failed to load favorite: $_", @("OK")) } }) # Add favorite button event $addFavBtn.add_Clicked({ try { $sub = $subredditInput.Text.ToString().Trim() if ($sub) { Write-Log -Message "TUI: Add favorite button clicked for: $sub" -Level Debug Add-Favorite -Subreddit $sub if ($script:Favorites -contains $sub) { $favoritesSource.Clear() foreach ($fav in $script:Favorites) { $favoritesSource.Add("r/$fav") } $favoritesList.SetSource($favoritesSource) } } } catch { Write-Log -Message "TUI: Failed to add favorite" -Level Error -ErrorRecord $_ [Terminal.Gui.MessageBox]::ErrorQuery("Error", "Failed to add favorite: $_", @("OK")) } }) # Remove favorite button event $removeFavBtn.add_Clicked({ try { $selected = $favoritesList.SelectedItem if ($selected -ge 0 -and $selected -lt $favoritesSource.Count) { $fav = $favoritesSource[$selected].ToString().Replace("r/", "") Write-Log -Message "TUI: Remove favorite button clicked for: $fav" -Level Debug Remove-Favorite -Subreddit $fav $favoritesSource.Clear() foreach ($f in $script:Favorites) { $favoritesSource.Add("r/$f") } $favoritesList.SetSource($favoritesSource) } } catch { Write-Log -Message "TUI: Failed to remove favorite" -Level Error -ErrorRecord $_ [Terminal.Gui.MessageBox]::ErrorQuery("Error", "Failed to remove favorite: $_", @("OK")) } }) # Add frames to window Write-Log -Message "TUI: Building UI components" -Level Debug $win.Add($favoritesFrame) $win.Add($contentFrame) $top.Add($win) # Create status bar with keyboard shortcut hints # Use [Terminal.Gui.Key]0 for display-only hints to avoid CLS casing # ambiguity (Terminal.Gui Key enum has both upper and lower letter variants) Write-Log -Message "TUI: Creating StatusBar with shortcut hints" -Level Debug $ctrlQ = [Terminal.Gui.Key]([uint32][Terminal.Gui.Key]::CtrlMask -bor [uint32][char]'q') Write-Log -Message "TUI: StatusBar: Ctrl+Q key value = $([int]$ctrlQ)" -Level Debug $statusBar = [Terminal.Gui.StatusBar]::new(@( [Terminal.Gui.StatusItem]::new(([Terminal.Gui.Key]0), "~Enter~ View", $null), [Terminal.Gui.StatusItem]::new(([Terminal.Gui.Key]0), "~O~ Open", $null), [Terminal.Gui.StatusItem]::new(([Terminal.Gui.Key]0), "~Tab~ Navigate", $null), [Terminal.Gui.StatusItem]::new($ctrlQ, "~^Q~ Quit", { [Terminal.Gui.Application]::RequestStop() }) )) Write-Log -Message "TUI: StatusBar created with 4 items, adding to top" -Level Debug $top.Add($statusBar) Write-Log -Message "TUI: StatusBar added to top" -Level Debug # Load initial subreddit & $loadPosts $InitialSubreddit # Run the application Write-Log -Message "TUI: Starting application main loop" -Level Info Write-Log -Message "TUI: About to call Terminal.Gui.Application.Run() with explicit toplevel" -Level Debug Write-Log -Message "TUI: Application state - Top: $($null -ne $top), Win: $($null -ne $win)" -Level Debug try { [Terminal.Gui.Application]::Run($top) Write-Log -Message "TUI: Application.Run() returned normally" -Level Debug } catch { $errorMsg = "TUI: CRASH - Exception in Application.Run()" $errorMsg += " | Exception Type: $($_.Exception.GetType().FullName)" $errorMsg += " | Message: $($_.Exception.Message)" if ($_.Exception.InnerException) { $errorMsg += " | Inner Exception: $($_.Exception.InnerException.GetType().FullName)" $errorMsg += " | Inner Message: $($_.Exception.InnerException.Message)" } Write-Log -Message $errorMsg -Level Error -ErrorRecord $_ throw } Write-Log -Message "TUI: Application main loop ended normally" -Level Debug } catch { $errorMsg = "TUI: Unhandled exception in application" $errorMsg += " | Exception Type: $($_.Exception.GetType().FullName)" $errorMsg += " | Message: $($_.Exception.Message)" if ($_.Exception.InnerException) { $errorMsg += " | Inner Exception: $($_.Exception.InnerException.GetType().FullName)" $errorMsg += " | Inner Message: $($_.Exception.InnerException.Message)" } Write-Log -Message $errorMsg -Level Error -ErrorRecord $_ Write-Host "`nAn error occurred in PSRedditTUI. Check the log for details: Get-PSRedditTUILog -Tail 50" -ForegroundColor Red throw } finally { Write-Log -Message "TUI: Shutting down application" -Level Info try { [Terminal.Gui.Application]::Shutdown() Write-Log -Message "TUI: Application shutdown complete" -Level Debug } catch { Write-Log -Message "TUI: Error during shutdown" -Level Error -ErrorRecord $_ } } } #endregion #region Dependency Management function Install-PSRedditTUITerminalGui { <# .SYNOPSIS Installs Terminal.Gui dependency for PSRedditTUI .DESCRIPTION Downloads Terminal.Gui and its NStack.Core dependency from NuGet and extracts them to ~/.psreddittui-packages/. Defaults to v1.16.0 which is the stable version used by Microsoft.PowerShell.ConsoleGuiTools. Terminal.Gui v2.x has compatibility issues with PowerShell and is not recommended. .PARAMETER Version The Terminal.Gui version to install (default: 1.16.0 - same as Microsoft.PowerShell.ConsoleGuiTools) .PARAMETER NStackVersion The NStack.Core version to install (default: 1.1.1 - required by Terminal.Gui 1.16.0) .PARAMETER Force Force reinstallation even if Terminal.Gui is already installed .EXAMPLE Install-PSRedditTUITerminalGui Installs Terminal.Gui 1.16.0 and NStack.Core 1.1.1 to the local package directory .EXAMPLE Install-PSRedditTUITerminalGui -Version "1.16.0" -Force Forces reinstallation of Terminal.Gui 1.16.0 and NStack.Core #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$Version = "1.16.0", [Parameter(Mandatory = $false)] [string]$NStackVersion = "1.1.1", [Parameter(Mandatory = $false)] [switch]$Force ) try { $packageDir = Join-Path $HOME ".psreddittui-packages" $terminalGuiExtractPath = Join-Path $packageDir "Terminal.Gui" $nstackExtractPath = Join-Path $packageDir "NStack.Core" $nstackDllPath = Join-Path $nstackExtractPath "lib/netstandard2.0/NStack.dll" # Try to find Terminal.Gui.dll in order of preference: net8.0, net7.0, netstandard2.1 $terminalGuiDllPath = $null $possiblePaths = @( "lib/net8.0/Terminal.Gui.dll", "lib/net7.0/Terminal.Gui.dll", "lib/netstandard2.1/Terminal.Gui.dll" ) foreach ($path in $possiblePaths) { $fullPath = Join-Path $terminalGuiExtractPath $path if (Test-Path $fullPath) { $terminalGuiDllPath = $fullPath break } } # Check if already installed (skip if -Force) if (-not $Force) { if ((Test-Path $terminalGuiDllPath) -and (Test-Path $nstackDllPath)) { Write-Log -Message "Terminal.Gui and NStack already installed" -Level Info Write-Information "Terminal.Gui and NStack are already installed. Use -Force to reinstall." -InformationAction Continue # Try to load if not already loaded (NStack first, then Terminal.Gui) if (-not ([System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'NStack' })) { try { Add-Type -Path $nstackDllPath -ErrorAction Stop Write-Log -Message "Loaded NStack assembly from: $nstackDllPath" -Level Info } catch { Write-Log -Message "Failed to load existing NStack assembly" -Level Error -ErrorRecord $_ } } if (-not ([System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'Terminal.Gui' })) { try { Add-Type -Path $terminalGuiDllPath -ErrorAction Stop Write-Log -Message "Loaded Terminal.Gui assembly from: $terminalGuiDllPath" -Level Info } catch { Write-Log -Message "Failed to load existing Terminal.Gui assembly" -Level Error -ErrorRecord $_ } } return [PSCustomObject]@{ TerminalGuiVersion = $Version NStackVersion = $NStackVersion TerminalGuiPath = $terminalGuiDllPath NStackPath = $nstackDllPath Loaded = $true Message = "Terminal.Gui and NStack already installed" } } } # Create package directory Write-Log -Message "Creating package directory: $packageDir" -Level Debug New-Item -ItemType Directory -Path $packageDir -Force | Out-Null # ===== Install NStack.Core first (dependency) ===== Write-Log -Message "Downloading NStack.Core $NStackVersion from NuGet..." -Level Info Write-Information "Downloading NStack.Core $NStackVersion..." -InformationAction Continue $nstackNupkgUrl = "https://www.nuget.org/api/v2/package/NStack.Core/$NStackVersion" $nstackNupkgPath = Join-Path $packageDir "NStack.Core.$NStackVersion.nupkg" Invoke-WebRequest -Uri $nstackNupkgUrl -OutFile $nstackNupkgPath -ErrorAction Stop Write-Log -Message "Downloaded NStack.Core to: $nstackNupkgPath" -Level Debug # Extract NStack package Write-Log -Message "Extracting NStack.Core to: $nstackExtractPath" -Level Info Write-Information "Extracting NStack.Core..." -InformationAction Continue if (Test-Path $nstackExtractPath) { Remove-Item -Path $nstackExtractPath -Recurse -Force } $nstackZipPath = $nstackNupkgPath -replace '\.nupkg$', '.zip' if (Test-Path $nstackZipPath) { Remove-Item -Path $nstackZipPath -Force } Copy-Item -Path $nstackNupkgPath -Destination $nstackZipPath -Force Expand-Archive -Path $nstackZipPath -DestinationPath $nstackExtractPath -Force Remove-Item -Path $nstackZipPath -Force -ErrorAction SilentlyContinue # Find NStack.dll if (-not (Test-Path $nstackDllPath)) { Write-Log -Message "netstandard2.0 NStack.dll not found, searching for alternatives..." -Level Debug $nstackDllPath = Get-ChildItem -Path $nstackExtractPath -Recurse -Filter "NStack.dll" | Select-Object -First 1 -ExpandProperty FullName } if (-not $nstackDllPath -or -not (Test-Path $nstackDllPath)) { $errorMsg = "Could not find NStack.dll in extracted package" Write-Log -Message $errorMsg -Level Error throw $errorMsg } Write-Log -Message "Found NStack.dll at: $nstackDllPath" -Level Info Write-Information "NStack.Core installed successfully!" -InformationAction Continue # ===== Install Terminal.Gui ===== Write-Log -Message "Downloading Terminal.Gui $Version from NuGet..." -Level Info Write-Information "Downloading Terminal.Gui $Version..." -InformationAction Continue $terminalGuiNupkgUrl = "https://www.nuget.org/api/v2/package/Terminal.Gui/$Version" $terminalGuiNupkgPath = Join-Path $packageDir "Terminal.Gui.$Version.nupkg" Invoke-WebRequest -Uri $terminalGuiNupkgUrl -OutFile $terminalGuiNupkgPath -ErrorAction Stop Write-Log -Message "Downloaded Terminal.Gui to: $terminalGuiNupkgPath" -Level Debug # Extract Terminal.Gui package Write-Log -Message "Extracting Terminal.Gui to: $terminalGuiExtractPath" -Level Info Write-Information "Extracting Terminal.Gui..." -InformationAction Continue if (Test-Path $terminalGuiExtractPath) { Remove-Item -Path $terminalGuiExtractPath -Recurse -Force } $terminalGuiZipPath = $terminalGuiNupkgPath -replace '\.nupkg$', '.zip' if (Test-Path $terminalGuiZipPath) { Remove-Item -Path $terminalGuiZipPath -Force } Copy-Item -Path $terminalGuiNupkgPath -Destination $terminalGuiZipPath -Force Expand-Archive -Path $terminalGuiZipPath -DestinationPath $terminalGuiExtractPath -Force Remove-Item -Path $terminalGuiZipPath -Force -ErrorAction SilentlyContinue # Find Terminal.Gui.dll - try preferred paths in order if (-not $terminalGuiDllPath -or -not (Test-Path $terminalGuiDllPath)) { Write-Log -Message "Searching for Terminal.Gui.dll in extracted package..." -Level Debug $terminalGuiDllPath = Get-ChildItem -Path $terminalGuiExtractPath -Recurse -Filter "Terminal.Gui.dll" | Where-Object { $_.FullName -match 'net[78]|netstandard' } | Select-Object -First 1 -ExpandProperty FullName } if (-not $terminalGuiDllPath -or -not (Test-Path $terminalGuiDllPath)) { $errorMsg = "Could not find Terminal.Gui.dll in extracted package" Write-Log -Message $errorMsg -Level Error throw $errorMsg } Write-Log -Message "Found Terminal.Gui.dll at: $terminalGuiDllPath" -Level Info Write-Information "Terminal.Gui installed successfully!" -InformationAction Continue # ===== Load assemblies (NStack first, then Terminal.Gui) ===== # Load NStack first (dependency) if (-not ([System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'NStack' })) { Write-Log -Message "Loading NStack assembly..." -Level Info Write-Information "Loading NStack assembly..." -InformationAction Continue Add-Type -Path $nstackDllPath -ErrorAction Stop Write-Log -Message "Successfully loaded NStack assembly" -Level Info Write-Information "Loaded: $nstackDllPath" -InformationAction Continue } else { Write-Log -Message "NStack assembly already loaded in current session" -Level Info } # Load Terminal.Gui if (-not ([System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'Terminal.Gui' })) { Write-Log -Message "Loading Terminal.Gui assembly..." -Level Info Write-Information "Loading Terminal.Gui assembly..." -InformationAction Continue Add-Type -Path $terminalGuiDllPath -ErrorAction Stop Write-Log -Message "Successfully loaded Terminal.Gui assembly" -Level Info Write-Information "Loaded: $terminalGuiDllPath" -InformationAction Continue } else { Write-Log -Message "Terminal.Gui assembly already loaded in current session" -Level Info } # Return success information return [PSCustomObject]@{ TerminalGuiVersion = $Version NStackVersion = $NStackVersion TerminalGuiPath = $terminalGuiDllPath NStackPath = $nstackDllPath Loaded = $true Message = "Terminal.Gui and NStack installed and loaded successfully" } } catch { Write-Log -Message "Failed to install Terminal.Gui and NStack" -Level Error -ErrorRecord $_ throw "Failed to install Terminal.Gui: $_`n`nFor manual installation instructions, run: Get-Help Install-PSRedditTUITerminalGui -Full" } } #endregion # Export module members Export-ModuleMember -Function @( 'Get-RedditData', 'Get-RedditPosts', 'Get-RedditComments', 'Search-Reddit', 'Get-Favorites', 'Add-Favorite', 'Remove-Favorite', 'Show-RedditTUI', 'Get-PSRedditTUILog', 'Clear-PSRedditTUILog', 'Set-PSRedditTUILogLevel', 'Install-PSRedditTUITerminalGui' ) |