Public/Start-EvergreenWorkbench.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Launches the Evergreen Workbench graphical interface. .DESCRIPTION Start-EvergreenUI is the single exported function of the EvergreenUI module. It checks that the Evergreen module is available, loads required WPF assemblies, builds the main window, and blocks until the window is closed. The function must be called from a thread with STA apartment state. In PowerShell 5.1 this is always the case. In PowerShell 7+ the host may be MTA; the function detects this and re-launches itself on an STA thread automatically. .EXAMPLE Start-EvergreenUI Opens the Evergreen Workbench window. All interaction happens inside the GUI. .NOTES - Windows only. - Requires the Evergreen module to be installed. - No parameters are accepted; all configuration is done inside the GUI and persisted to $env:APPDATA\EvergreenUI\settings.json. #> [CmdletBinding()] param() Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # STA guard (PowerShell 7+ may start MTA) if ([System.Threading.Thread]::CurrentThread.ApartmentState -ne 'STA') { Write-Verbose 'Current thread is MTA - restarting on an STA thread.' $sta = [powershell]::Create() $sta.AddScript({ Import-Module EvergreenUI; Start-EvergreenUI }) | Out-Null $runspace = [runspacefactory]::CreateRunspace() $runspace.ApartmentState = 'STA' $runspace.ThreadOptions = 'ReuseThread' $runspace.Open() $sta.Runspace = $runspace $sta.Invoke() $sta.Dispose() $runspace.Dispose() return } # Dependency check Test-EvergreenModule # Load WPF assemblies Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName WindowsBase Add-Type -AssemblyName System.Windows.Forms # Load saved config $config = Get-UIConfig # Shared state $syncHash = [hashtable]::Synchronized(@{ Window = $null LogTextBox = $null LogScrollViewer = $null IsRunning = $false AppList = $null CurrentAppResults = $null FilterState = @{} VersionsListView = $null ResultsCountLabel = $null DownloadQueueListView = $null QueueCountLabel = $null DownloadAllButton = $null LibraryContentsListView = $null LibraryDetailsListView = $null LibraryStatusLabel = $null LibraryUpdateButton = $null LibraryData = @() ActiveBackgroundOperations = [System.Collections.Generic.List[object]]::new() BackgroundOperationsTimer = $null DownloadQueue = [System.Collections.Generic.List[PSCustomObject]]::new() EvergreenVersion = '' Config = $config PendingLoadTimer = $null PendingLoadPS = $null PendingLoadRunspace = $null PendingLoadAsync = $null PendingLoadAppName = $null }) # Load XAML layout $xamlPath = Join-Path -Path (Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath "..") -ChildPath "Resources") -ChildPath "EvergreenUI.xaml" $stream = [System.IO.File]::OpenRead((Resolve-Path -Path $xamlPath).Path) $window = [System.Windows.Markup.XamlReader]::Load($stream) $stream.Dispose() # Resolve named controls $syncHash.Window = $window $syncHash.LogTextBox = $window.FindName('LogTextBox') $syncHash.LogScrollViewer = $window.FindName('LogScrollViewer') $rootGrid = $window.FindName('RootGrid') $evergreenVersionText = $window.FindName('EvergreenVersionText') $evergreenStatusDot = $window.FindName('EvergreenStatusDot') $themeComboBox = $window.FindName('ThemeComboBox') $navApps = $window.FindName('NavApps') $navDownload = $window.FindName('NavDownload') $navLibrary = $window.FindName('NavLibrary') $navSettings = $window.FindName('NavSettings') $appsPanel = $window.FindName('AppsPanel') $downloadPanel = $window.FindName('DownloadPanel') $libraryPanel = $window.FindName('LibraryPanel') $settingsPanel = $window.FindName('SettingsPanel') $refreshAppsButton = $window.FindName('RefreshAppsButton') $appSearchBox = $window.FindName('AppSearchBox') $appsComboBox = $window.FindName('AppsComboBox') $loadAppVersionsButton = $window.FindName('LoadAppVersionsButton') $filterWrapPanel = $window.FindName('FilterWrapPanel') $clearFiltersButton = $window.FindName('ClearFiltersButton') $exportCsvButton = $window.FindName('ExportCsvButton') $addToQueueButton = $window.FindName('AddToQueueButton') $removeQueueItemButton = $window.FindName('RemoveQueueItemButton') $clearQueueButton = $window.FindName('ClearQueueButton') $openDownloadFolderButton = $window.FindName('OpenDownloadFolderButton') $libraryPathViewBox = $window.FindName('LibraryPathViewBox') $libraryBrowseButton = $window.FindName('LibraryBrowseButton') $libraryNewButton = $window.FindName('LibraryNewButton') $libraryRefreshButton = $window.FindName('LibraryRefreshButton') $libraryOpenFolderButton = $window.FindName('LibraryOpenFolderButton') $syncHash.LibraryContentsListView = $window.FindName('LibraryContentsListView') $syncHash.LibraryDetailsListView = $window.FindName('LibraryDetailsListView') $syncHash.LibraryStatusLabel = $window.FindName('LibraryStatusLabel') $syncHash.LibraryUpdateButton = $window.FindName('LibraryUpdateButton') $syncHash.DownloadQueueListView = $window.FindName('DownloadQueueListView') $syncHash.QueueCountLabel = $window.FindName('QueueCountLabel') $syncHash.DownloadAllButton = $window.FindName('DownloadAllButton') $syncHash.VersionsListView = $window.FindName('VersionsListView') $syncHash.ResultsCountLabel = $window.FindName('ResultsCountLabel') $appCountLabel = $window.FindName('AppCountLabel') $appDetailEmpty = $window.FindName('AppDetailEmpty') $appDetailLoading = $window.FindName('AppDetailLoading') $appDetailLoadingLabel = $window.FindName('AppDetailLoadingLabel') $appDetailContent = $window.FindName('AppDetailContent') $appDetailTitle = $window.FindName('AppDetailTitle') $copyLogButton = $window.FindName('CopyLogButton') $saveLogButton = $window.FindName('SaveLogButton') $logToggleButton = $window.FindName('LogToggleButton') $outputPathBox = $window.FindName('OutputPathBox') $evergreenAppsPathBox = $window.FindName('EvergreenAppsPathBox') $logVerbosityComboBox = $window.FindName('LogVerbosityComboBox') $startupViewComboBox = $window.FindName('StartupViewComboBox') $browseOutputButton = $window.FindName('BrowseOutputButton') $openEvergreenAppsFolderButton = $window.FindName('OpenEvergreenAppsFolderButton') $clearCacheButton = $window.FindName('ClearCacheButton') $openCacheFolderButton = $window.FindName('OpenCacheFolderButton') # Log row is RowDefinitions[3]; track its height for collapse/restore $logRowDef = $rootGrid.RowDefinitions[3] # Apply persisted window size with safe minimums $window.Width = [Math]::Max(900, [double]$syncHash.Config.WindowWidth) $window.Height = [Math]::Max(600, [double]$syncHash.Config.WindowHeight) # Apps view helpers $updateAppsComboSource = { param([string]$SearchText = '') $allApps = @($syncHash.AppList) if ($allApps.Count -eq 0) { $appsComboBox.ItemsSource = @() $appCountLabel.Text = '' return } if ([string]::IsNullOrWhiteSpace($SearchText)) { $appsComboBox.ItemsSource = $allApps $appCountLabel.Text = " $($allApps.Count) of $($allApps.Count)" return } $needle = $SearchText.Trim() $filtered = $allApps | Where-Object { $_.Name -like "*$needle*" -or $_.FriendlyName -like "*$needle*" } $appsComboBox.ItemsSource = @($filtered) $appCountLabel.Text = " $(@($filtered).Count) of $($allApps.Count)" } $loadAppCatalog = { param([switch]$Force) $refreshAppsButton.IsEnabled = $false try { [void](Get-EvergreenAppList -SyncHash $syncHash -Force:$Force) & $updateAppsComboSource -SearchText $appSearchBox.Text } finally { $refreshAppsButton.IsEnabled = $true } } # Rebuilds the VersionsListView GridView columns to match the properties returned # by Get-EvergreenApp for the current app. Version is always first, URI always last. $rebuildVersionColumns = { param([PSObject[]]$AppResults) if ($null -eq $AppResults -or $AppResults.Count -eq 0) { return } # Guard against double-wrapped data: if element 0 is itself an array, flatten one level. if ($AppResults[0] -is [System.Array]) { $AppResults = @($AppResults[0]) if ($AppResults.Count -eq 0) { return } } $allProps = [string[]]$AppResults[0].PSObject.Properties.Name # Well-known preferred widths $widths = @{ Version = 140 Architecture = 110 Channel = 130 Release = 100 Platform = 90 Language = 90 Ring = 110 Track = 90 Type = 80 Product = 110 Date = 100 URI = 460 } # Order: Version first, URI last, everything else in declared order $skip = [System.Collections.Generic.HashSet[string]]::new( [string[]]@('Version', 'URI'), [System.StringComparer]::OrdinalIgnoreCase ) $middle = $allProps | Where-Object { -not $skip.Contains($_) } $ordered = @( if ($allProps -contains 'Version') { 'Version' } ) + @($middle) + @( if ($allProps -contains 'URI') { 'URI' } ) $gv = [System.Windows.Controls.GridView]::new() foreach ($prop in $ordered) { $col = [System.Windows.Controls.GridViewColumn]::new() $col.Header = $prop $col.DisplayMemberBinding = [System.Windows.Data.Binding]::new($prop) $col.Width = if ($widths.ContainsKey($prop)) { $widths[$prop] } else { 100 } [void]$gv.Columns.Add($col) } $syncHash.VersionsListView.View = $gv } # Returns the cache file path for a given app name, creating the cache directory if needed. $getAppCacheFile = { param([string]$AppName) $cacheDir = Join-Path $env:APPDATA 'EvergreenUI\cache' if (-not (Test-Path -LiteralPath $cacheDir)) { $null = New-Item -ItemType Directory -Path $cacheDir -Force } Join-Path $cacheDir "$AppName.json" } # Populates the detail panel from a result array (used for both live and cached data). $displayAppResults = { param([PSObject[]]$AppResults) $syncHash.CurrentAppResults = @($AppResults) & $rebuildVersionColumns -AppResults $syncHash.CurrentAppResults $filterProps = @(Get-FilterableProperties -AppResults $syncHash.CurrentAppResults) New-FilterPanel -FilterProperties $filterProps -WrapPanel $filterWrapPanel -SyncHash $syncHash -OnChangeCallback { Invoke-FilterUpdate -SyncHash $syncHash } Invoke-FilterUpdate -SyncHash $syncHash $appDetailLoading.Visibility = [System.Windows.Visibility]::Collapsed $appDetailContent.Visibility = [System.Windows.Visibility]::Visible } $loadAppVersions = { $selectedApp = $appsComboBox.SelectedItem if ($null -eq $selectedApp) { Write-UILog -SyncHash $syncHash -Message 'Select an application first.' -Level Warning return } $appName = [string]$selectedApp.Name $loadAppVersionsButton.IsEnabled = $false # Show loading state $appDetailContent.Visibility = [System.Windows.Visibility]::Collapsed $appDetailLoading.Visibility = [System.Windows.Visibility]::Visible $appDetailLoadingLabel.Text = "Retrieving details for $appName with Evergreen..." Write-UILog -SyncHash $syncHash -Message "Loading versions for $appName..." -Level Info Write-UILog -SyncHash $syncHash -Message "Get-EvergreenApp -Name '$appName'" -Level Cmd $runspace = New-WpfRunspace -SyncHash $syncHash $ps = [powershell]::Create() $ps.Runspace = $runspace [void]$ps.AddScript({ param([string]$Name) Get-EvergreenApp -Name $Name -ErrorAction Stop }).AddArgument($appName) # Store async state in syncHash so the tick handler and cancellation logic can reach it $syncHash.PendingLoadPS = $ps $syncHash.PendingLoadRunspace = $runspace $syncHash.PendingLoadAppName = $appName $syncHash.PendingLoadAsync = $ps.BeginInvoke() $pollTimer = [System.Windows.Threading.DispatcherTimer]::new() $pollTimer.Interval = [TimeSpan]::FromMilliseconds(300) $syncHash.PendingLoadTimer = $pollTimer $pollTimer.add_Tick({ # Guard against null (can happen if cancelled between ticks) if ($null -eq $syncHash.PendingLoadAsync -or -not $syncHash.PendingLoadAsync.IsCompleted) { return } $syncHash.PendingLoadTimer.Stop() $syncHash.PendingLoadTimer = $null # Grab refs before clearing syncHash slots $currentPS = $syncHash.PendingLoadPS $currentRunspace = $syncHash.PendingLoadRunspace $currentAsync = $syncHash.PendingLoadAsync $currentAppName = $syncHash.PendingLoadAppName $syncHash.PendingLoadPS = $null $syncHash.PendingLoadRunspace = $null $syncHash.PendingLoadAsync = $null $syncHash.PendingLoadAppName = $null try { $results = @($currentPS.EndInvoke($currentAsync)) $currentPS.Dispose() $currentRunspace.Dispose() # Save results to cache $cachePath = & $getAppCacheFile -AppName $currentAppName try { $results | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $cachePath -Encoding UTF8 -Force } catch { Write-UILog -SyncHash $syncHash -Message "Failed to write cache for ${currentAppName}: $_" -Level Warning } & $displayAppResults -AppResults $results Write-UILog -SyncHash $syncHash -Message "Loaded $($syncHash.CurrentAppResults.Count) versions for $currentAppName." -Level Info } catch { try { $currentPS.Dispose() } catch {} try { $currentRunspace.Dispose() } catch {} $syncHash.CurrentAppResults = @() $syncHash.VersionsListView.ItemsSource = @() $syncHash.ResultsCountLabel.Text = 'Showing 0 of 0' $filterWrapPanel.Children.Clear() $appDetailLoading.Visibility = [System.Windows.Visibility]::Collapsed $appDetailEmpty.Visibility = [System.Windows.Visibility]::Visible Write-UILog -SyncHash $syncHash -Message "Failed to load versions for ${currentAppName}: $_" -Level Error } finally { $loadAppVersionsButton.IsEnabled = $true } }) $pollTimer.Start() } $refreshQueueView = { $syncHash.DownloadQueueListView.ItemsSource = $null $syncHash.DownloadQueueListView.ItemsSource = $syncHash.DownloadQueue $syncHash.DownloadQueueListView.Items.Refresh() $pending = @($syncHash.DownloadQueue | Where-Object { $_.Status -eq 'Pending' }).Count $done = @($syncHash.DownloadQueue | Where-Object { $_.Status -eq 'Done' }).Count $failed = @($syncHash.DownloadQueue | Where-Object { $_.Status -eq 'Failed' }).Count $total = $syncHash.DownloadQueue.Count $syncHash.QueueCountLabel.Text = "Queue: $total items (Pending: $pending, Done: $done, Failed: $failed)" } $normalizeDirectoryPath = { param([string]$PathValue) if ([string]::IsNullOrWhiteSpace($PathValue)) { return '' } return $PathValue.Trim().Trim('"') } $registerBackgroundOperation = { param( [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][System.Management.Automation.PowerShell]$PowerShellInstance, [Parameter(Mandatory)][System.Management.Automation.Runspaces.Runspace]$RunspaceInstance, [Parameter(Mandatory)]$AsyncResult ) $operation = [PSCustomObject]@{ Name = $Name PowerShell = $PowerShellInstance Runspace = $RunspaceInstance Async = $AsyncResult } $syncHash.ActiveBackgroundOperations.Add($operation) if ($null -eq $syncHash.BackgroundOperationsTimer) { $timer = [System.Windows.Threading.DispatcherTimer]::new() $timer.Interval = [TimeSpan]::FromMilliseconds(500) $timer.add_Tick({ $completed = @($syncHash.ActiveBackgroundOperations | Where-Object { $_.Async.IsCompleted }) foreach ($op in $completed) { try { [void]$op.PowerShell.EndInvoke($op.Async) } catch { Write-UILog -SyncHash $syncHash -Message "Background operation '$($op.Name)' completed with error: $_" -Level Error } finally { try { $op.PowerShell.Dispose() } catch {} try { $op.Runspace.Dispose() } catch {} [void]$syncHash.ActiveBackgroundOperations.Remove($op) } } if ($syncHash.ActiveBackgroundOperations.Count -eq 0) { $syncHash.BackgroundOperationsTimer.Stop() } }) $syncHash.BackgroundOperationsTimer = $timer } if (-not $syncHash.BackgroundOperationsTimer.IsEnabled) { $syncHash.BackgroundOperationsTimer.Start() } } $startQueueDownload = { if ($syncHash.IsRunning) { Write-UILog -SyncHash $syncHash -Message 'A queue operation is already running.' -Level Warning return } if ($syncHash.DownloadQueue.Count -eq 0) { Write-UILog -SyncHash $syncHash -Message 'Queue is empty. Add items from Apps view first.' -Level Warning return } $outputPath = & $normalizeDirectoryPath -PathValue $syncHash.Config.OutputPath if ([string]::IsNullOrWhiteSpace($outputPath)) { Write-UILog -SyncHash $syncHash -Message 'Set a download output path in Settings before starting queue downloads.' -Level Warning return } if (-not (Test-Path -LiteralPath $outputPath -PathType Container)) { try { [void](New-Item -Path $outputPath -ItemType Directory -Force -ErrorAction Stop) } catch { Write-UILog -SyncHash $syncHash -Message "Could not create output path '$outputPath': $_" -Level Error return } } $syncHash.Config.OutputPath = $outputPath $outputPathBox.Text = $outputPath Set-UIConfig -Config $syncHash.Config $syncHash.IsRunning = $true $syncHash.DownloadAllButton.IsEnabled = $false $privateRoot = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'Private' $writeUILogPath = Join-Path -Path $privateRoot -ChildPath 'Write-UILog.ps1' $invokeDownloadPath = Join-Path -Path $privateRoot -ChildPath 'Invoke-AppDownload.ps1' $rs = New-WpfRunspace -SyncHash $syncHash $ps = [powershell]::Create() $ps.Runspace = $rs [void]$ps.AddScript({ param( [string]$WriteUILogPath, [string]$InvokeDownloadPath ) . $WriteUILogPath . $InvokeDownloadPath try { Import-Module Evergreen -ErrorAction Stop | Out-Null } catch { Write-UILog -SyncHash $syncHash -Message "Failed to import Evergreen in background runspace: $_" -Level Error } try { Write-UILog -SyncHash $syncHash -Message 'Starting queue download run (sequential).' -Level Info foreach ($item in @($syncHash.DownloadQueue)) { if ($item.Status -eq 'Done') { continue } Invoke-AppDownload -SyncHash $syncHash -QueueItem $item } Write-UILog -SyncHash $syncHash -Message 'Queue download run finished.' -Level Info } catch { Write-UILog -SyncHash $syncHash -Message "Queue download run failed: $_" -Level Error } finally { # Pre-compute counts on the background thread before dispatching. $pendingCount = @($syncHash.DownloadQueue | Where-Object { $_.Status -eq 'Pending' }).Count $doneCount = @($syncHash.DownloadQueue | Where-Object { $_.Status -eq 'Done' }).Count $failedCount = @($syncHash.DownloadQueue | Where-Object { $_.Status -eq 'Failed' }).Count $totalCount = $syncHash.DownloadQueue.Count $finalQueueText = "Queue: $totalCount items (Pending: $pendingCount, Done: $doneCount, Failed: $failedCount)" $syncHash.Window.Dispatcher.Invoke([action] { $syncHash.IsRunning = $false if ($null -ne $syncHash.DownloadAllButton) { $syncHash.DownloadAllButton.IsEnabled = $true } if ($null -ne $syncHash.DownloadQueueListView) { $syncHash.DownloadQueueListView.Items.Refresh() } if ($null -ne $syncHash.QueueCountLabel) { $syncHash.QueueCountLabel.Text = $finalQueueText } }, 'Normal') } }).AddArgument($writeUILogPath).AddArgument($invokeDownloadPath) $async = $ps.BeginInvoke() & $registerBackgroundOperation -Name 'QueueDownload' -PowerShellInstance $ps -RunspaceInstance $rs -AsyncResult $async } $getLibraryItemName = { param([PSObject]$Item) if ($null -eq $Item) { return '' } foreach ($candidate in @('Name', 'AppName', 'Application', 'Product')) { if ($Item.PSObject.Properties.Name -contains $candidate -and -not [string]::IsNullOrWhiteSpace([string]$Item.$candidate)) { return [string]$Item.$candidate } } return [string]$Item } $refreshLibraryView = { $path = $libraryPathViewBox.Text if ([string]::IsNullOrWhiteSpace($path)) { $syncHash.LibraryStatusLabel.Text = 'Set a library path to load library contents.' $syncHash.LibraryContentsListView.ItemsSource = @() $syncHash.LibraryDetailsListView.ItemsSource = @() return } if (-not (Test-Path -LiteralPath $path -PathType Container)) { $syncHash.LibraryStatusLabel.Text = "Library path does not exist: $path" $syncHash.LibraryContentsListView.ItemsSource = @() $syncHash.LibraryDetailsListView.ItemsSource = @() return } try { $syncHash.Config.LibraryPath = $path Set-UIConfig -Config $syncHash.Config Write-UILog -SyncHash $syncHash -Message "Get-EvergreenLibrary -Path '$path'" -Level Cmd $items = @() $libraryObj = Get-EvergreenLibrary -Path $path -ErrorAction Stop $inventory = if ($libraryObj.PSObject.Properties.Name -contains 'Inventory') { @($libraryObj.Inventory) } else { @($libraryObj) } foreach ($entry in $inventory) { $appName = if ($entry.PSObject.Properties.Name -contains 'ApplicationName') { [string]$entry.ApplicationName } else { & $getLibraryItemName -Item $entry } $versions = if ($entry.PSObject.Properties.Name -contains 'Versions') { $entry.Versions } else { $null } $versionCount = if ($null -ne $versions) { @($versions).Count } else { 0 } $appPath = Join-Path -Path $path -ChildPath $appName $items += [PSCustomObject]@{ Name = $appName VersionCount = $versionCount Path = $appPath SourceItem = $entry } } $syncHash.LibraryData = @($items) $syncHash.LibraryContentsListView.ItemsSource = $syncHash.LibraryData $syncHash.LibraryDetailsListView.ItemsSource = @() $syncHash.LibraryStatusLabel.Text = "Loaded $($syncHash.LibraryData.Count) library apps." Write-UILog -SyncHash $syncHash -Message "Library loaded from $path ($($syncHash.LibraryData.Count) apps)." -Level Info } catch { $syncHash.LibraryData = @() $syncHash.LibraryContentsListView.ItemsSource = @() $syncHash.LibraryDetailsListView.ItemsSource = @() $syncHash.LibraryStatusLabel.Text = 'Failed to load library.' Write-UILog -SyncHash $syncHash -Message "Failed to load library: $_" -Level Error } } $syncHash.RefreshLibraryView = $refreshLibraryView $loadLibraryAppDetails = { param([PSObject]$SelectedLibraryItem) if ($null -eq $SelectedLibraryItem) { $syncHash.LibraryDetailsListView.ItemsSource = @() return } $appName = [string]$SelectedLibraryItem.Name $versions = $null if ($SelectedLibraryItem.PSObject.Properties.Name -contains 'SourceItem' -and $null -ne $SelectedLibraryItem.SourceItem -and $SelectedLibraryItem.SourceItem.PSObject.Properties.Name -contains 'Versions') { $versions = $SelectedLibraryItem.SourceItem.Versions } if ($null -eq $versions) { $syncHash.LibraryDetailsListView.ItemsSource = @() $syncHash.LibraryStatusLabel.Text = "No version details found for $appName." return } $versionArray = @($versions) # Build columns dynamically from the first item's properties, Version always first $gridView = [System.Windows.Controls.GridView]::new() if ($versionArray.Count -gt 0) { $allProps = $versionArray[0].PSObject.Properties.Name $orderedProps = @('Version') + ($allProps | Where-Object { $_ -ne 'Version' }) foreach ($prop in $orderedProps) { $col = [System.Windows.Controls.GridViewColumn]::new() $col.Header = $prop $col.DisplayMemberBinding = [System.Windows.Data.Binding]::new($prop) $col.Width = if ($prop -match 'URI|Url|Path') { 400 } elseif ($prop -eq 'Version') { 130 } else { 110 } $gridView.Columns.Add($col) } } $syncHash.LibraryDetailsListView.View = $gridView $syncHash.LibraryDetailsListView.ItemsSource = $versionArray $syncHash.LibraryStatusLabel.Text = "Details loaded for $appName." } $startLibraryUpdate = { if ($syncHash.IsRunning) { Write-UILog -SyncHash $syncHash -Message 'Another operation is currently running.' -Level Warning return } $path = $libraryPathViewBox.Text if ([string]::IsNullOrWhiteSpace($path)) { Write-UILog -SyncHash $syncHash -Message 'Set a library path before updating.' -Level Warning return } if (-not (Test-Path -LiteralPath $path -PathType Container)) { Write-UILog -SyncHash $syncHash -Message "Library path does not exist: $path" -Level Error return } $syncHash.Config.LibraryPath = $path Set-UIConfig -Config $syncHash.Config $syncHash.IsRunning = $true $syncHash.LibraryUpdateButton.IsEnabled = $false $privateRoot = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'Private' $writeUILogPath = Join-Path -Path $privateRoot -ChildPath 'Write-UILog.ps1' $invokeLibraryUpdatePath = Join-Path -Path $privateRoot -ChildPath 'Invoke-LibraryUpdate.ps1' $rs = New-WpfRunspace -SyncHash $syncHash $ps = [powershell]::Create() $ps.Runspace = $rs [void]$ps.AddScript({ param( [string]$WriteUILogPath, [string]$InvokeLibraryUpdatePath ) . $WriteUILogPath . $InvokeLibraryUpdatePath try { Import-Module Evergreen -ErrorAction Stop | Out-Null Invoke-LibraryUpdate -SyncHash $syncHash } catch { Write-UILog -SyncHash $syncHash -Message "Library update run failed: $_" -Level Error } finally { $syncHash.Window.Dispatcher.Invoke([action] { $syncHash.IsRunning = $false if ($null -ne $syncHash.LibraryUpdateButton) { $syncHash.LibraryUpdateButton.IsEnabled = $true } & $syncHash.RefreshLibraryView }, 'Normal') } }).AddArgument($writeUILogPath).AddArgument($invokeLibraryUpdatePath) $async = $ps.BeginInvoke() & $registerBackgroundOperation -Name 'LibraryUpdate' -PowerShellInstance $ps -RunspaceInstance $rs -AsyncResult $async } # Apply initial log state from config $isLogVisible = [bool]$syncHash.Config.LogVisible if ($isLogVisible) { $initialLogHeight = [Math]::Max(80, [int]$syncHash.Config.LogHeight) $logRowDef.Height = [System.Windows.GridLength]::new(40 + $initialLogHeight) $logToggleButton.IsChecked = $true $logToggleButton.Content = 'Hide progress log' } else { $logRowDef.Height = [System.Windows.GridLength]::new(40) $logToggleButton.IsChecked = $false $logToggleButton.Content = 'Show progress log' } # Event: Window.Loaded $window.add_Loaded({ # Apply saved theme (before any logging so colours are correct) if ($syncHash.Config.Theme -eq 'Dark') { $themeComboBox.SelectedIndex = 1 Set-DarkTheme -Window $syncHash.Window } else { $themeComboBox.SelectedIndex = 0 Set-LightTheme -Window $syncHash.Window } # Populate Evergreen version info in title bar try { $egModule = Get-Module -Name Evergreen | Select-Object -First 1 if ($null -ne $egModule) { $syncHash.EvergreenVersion = "v$($egModule.Version)" $evergreenVersionText.Text = "Evergreen $($syncHash.EvergreenVersion)" $evergreenStatusDot.Fill = [System.Windows.Media.Brushes]::LightGreen } else { $evergreenVersionText.Text = 'Evergreen: not loaded' $evergreenStatusDot.Fill = [System.Windows.Media.Brushes]::OrangeRed } } catch { $evergreenVersionText.Text = 'Evergreen: error' $evergreenStatusDot.Fill = [System.Windows.Media.Brushes]::OrangeRed } Write-UILog -SyncHash $syncHash -Message "EvergreenUI started. $($syncHash.EvergreenVersion)" -Level Info & $loadAppCatalog if (-not [string]::IsNullOrWhiteSpace($syncHash.Config.LastAppName)) { $savedApp = @($syncHash.AppList | Where-Object { $_.Name -eq $syncHash.Config.LastAppName } | Select-Object -First 1) if ($savedApp.Count -gt 0) { $appsComboBox.SelectedItem = $savedApp[0] $appsComboBox.ScrollIntoView($savedApp[0]) } } & $refreshQueueView $libraryPathViewBox.Text = $syncHash.Config.LibraryPath switch ([string]$syncHash.Config.StartupView) { 'Download' { $navDownload.IsChecked = $true } 'Library' { $navLibrary.IsChecked = $true } 'Settings' { $navSettings.IsChecked = $true } default { $navApps.IsChecked = $true } } }) # Event: Window.Closing - persist config $window.add_Closing({ try { $syncHash.Config.LogVisible = [bool]$logToggleButton.IsChecked if ($syncHash.Config.LogVisible) { $currentLogHeight = [int]$logRowDef.Height.Value - 40 if ($currentLogHeight -gt 0) { $syncHash.Config.LogHeight = $currentLogHeight } } $syncHash.Config.Theme = if ($themeComboBox.SelectedIndex -eq 1) { 'Dark' } else { 'Light' } $syncHash.Config.WindowWidth = [int]$window.Width $syncHash.Config.WindowHeight = [int]$window.Height $syncHash.Config.LastAppName = if ($null -ne $appsComboBox.SelectedItem) { [string]$appsComboBox.SelectedItem.Name } else { '' } $syncHash.Config.StartupView = if ($navDownload.IsChecked) { 'Download' } elseif ($navLibrary.IsChecked) { 'Library' } elseif ($navSettings.IsChecked) { 'Settings' } else { 'Apps' } Set-UIConfig -Config $syncHash.Config if ($null -ne $syncHash.BackgroundOperationsTimer -and $syncHash.BackgroundOperationsTimer.IsEnabled) { $syncHash.BackgroundOperationsTimer.Stop() } foreach ($op in @($syncHash.ActiveBackgroundOperations)) { try { $op.PowerShell.Stop() } catch {} try { $op.PowerShell.Dispose() } catch {} try { $op.Runspace.Dispose() } catch {} } $syncHash.ActiveBackgroundOperations.Clear() } catch { # Never block window close for a config-save failure } }) # Keyboard shortcuts (Phase 8 polish) # Ctrl+F: focus app search # Ctrl+,: open settings # Ctrl+D: start queue download (when Download view active) # Ctrl+U: start library update (when Library view active) # Ctrl+L: toggle log panel # F5: refresh current active view $window.add_PreviewKeyDown({ param($sender, $e) $mods = [System.Windows.Input.Keyboard]::Modifiers $ctrl = ($mods -band [System.Windows.Input.ModifierKeys]::Control) -ne 0 if ($e.Key -eq [System.Windows.Input.Key]::F5) { if ($navApps.IsChecked) { & $loadAppCatalog -Force } elseif ($navDownload.IsChecked) { & $refreshQueueView } elseif ($navLibrary.IsChecked) { & $refreshLibraryView } $e.Handled = $true return } if (-not $ctrl) { return } switch ($e.Key) { ([System.Windows.Input.Key]::F) { $navApps.IsChecked = $true [void]$appSearchBox.Focus() $appSearchBox.SelectAll() $e.Handled = $true } ([System.Windows.Input.Key]::OemComma) { $navSettings.IsChecked = $true $e.Handled = $true } ([System.Windows.Input.Key]::D) { if ($navDownload.IsChecked) { & $startQueueDownload $e.Handled = $true } } ([System.Windows.Input.Key]::U) { if ($navLibrary.IsChecked) { & $startLibraryUpdate $e.Handled = $true } } ([System.Windows.Input.Key]::L) { $logToggleButton.IsChecked = -not $logToggleButton.IsChecked $logToggleButton.RaiseEvent([System.Windows.RoutedEventArgs]::new([System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) $e.Handled = $true } } }) # Navigation: Checked handler swaps content panels $panelMap = @{ NavApps = $appsPanel NavDownload = $downloadPanel NavLibrary = $libraryPanel NavSettings = $settingsPanel } $navCheckedHandler = { param($s, $e) foreach ($entry in $panelMap.GetEnumerator()) { $entry.Value.Visibility = if ($entry.Key -eq $s.Name) { [System.Windows.Visibility]::Visible } else { [System.Windows.Visibility]::Collapsed } } } foreach ($navBtn in @($navApps, $navDownload, $navLibrary, $navSettings)) { $navBtn.add_Checked($navCheckedHandler) } $navApps.add_Checked({ if ($null -eq $syncHash.AppList -or $syncHash.AppList.Count -eq 0) { & $loadAppCatalog } }) $navDownload.add_Checked({ & $refreshQueueView }) $navLibrary.add_Checked({ if ([string]::IsNullOrWhiteSpace($libraryPathViewBox.Text)) { $libraryPathViewBox.Text = $syncHash.Config.LibraryPath } if (-not [string]::IsNullOrWhiteSpace($libraryPathViewBox.Text)) { & $refreshLibraryView } }) $refreshAppsButton.add_Click({ Write-UILog -SyncHash $syncHash -Message 'Refreshing Evergreen app catalog...' -Level Info & $loadAppCatalog -Force }) $appSearchBox.add_TextChanged({ & $updateAppsComboSource -SearchText $appSearchBox.Text }) $loadAppVersionsButton.add_Click({ & $loadAppVersions }) $appsComboBox.add_SelectionChanged({ # Cancel any in-progress version load before starting a new one if ($null -ne $syncHash.PendingLoadTimer -and $syncHash.PendingLoadTimer.IsEnabled) { $syncHash.PendingLoadTimer.Stop() $syncHash.PendingLoadTimer = $null } if ($null -ne $syncHash.PendingLoadPS) { try { $syncHash.PendingLoadPS.Stop() } catch {} try { $syncHash.PendingLoadPS.Dispose() } catch {} $syncHash.PendingLoadPS = $null } if ($null -ne $syncHash.PendingLoadRunspace) { try { $syncHash.PendingLoadRunspace.Dispose() } catch {} $syncHash.PendingLoadRunspace = $null } $syncHash.PendingLoadAsync = $null $syncHash.PendingLoadAppName = $null $syncHash.CurrentAppResults = @() $syncHash.VersionsListView.ItemsSource = @() $syncHash.ResultsCountLabel.Text = 'Showing 0 of 0' $filterWrapPanel.Children.Clear() $syncHash.FilterState = @{} $selectedApp = $appsComboBox.SelectedItem if ($null -ne $selectedApp) { $appDetailTitle.Text = "$($selectedApp.Name) Version Details" # Load from cache if available; otherwise show the panel empty (user clicks Refresh) $cachePath = & $getAppCacheFile -AppName $selectedApp.Name if (Test-Path -LiteralPath $cachePath) { try { $rawJson = Get-Content -LiteralPath $cachePath -Raw $parsed = ConvertFrom-Json -InputObject $rawJson # Guard against double-wrapping: @() can treat the Object[] returned by # ConvertFrom-Json as a single item in certain PS/WPF execution contexts, # producing Object[]{ Object[]{realItems} }. Detect and flatten one level. $cachedResults = if ($parsed -is [System.Array] -and $parsed.Count -gt 0 -and $parsed[0] -is [System.Array]) { [object[]]$parsed[0] } elseif ($parsed -is [System.Array]) { [object[]]$parsed } else { @($parsed) } Write-UILog -SyncHash $syncHash -Message "Loaded $($cachedResults.Count) cached versions for $($selectedApp.Name)." -Level Info & $displayAppResults -AppResults $cachedResults } catch { Write-UILog -SyncHash $syncHash -Message "Cache read failed for $($selectedApp.Name), click Refresh to load: $_" -Level Warning $filterWrapPanel.Children.Clear() $syncHash.FilterState = @{} $appDetailEmpty.Visibility = [System.Windows.Visibility]::Collapsed $appDetailLoading.Visibility = [System.Windows.Visibility]::Collapsed $appDetailContent.Visibility = [System.Windows.Visibility]::Visible } } else { $appDetailEmpty.Visibility = [System.Windows.Visibility]::Collapsed $appDetailLoading.Visibility = [System.Windows.Visibility]::Collapsed $appDetailContent.Visibility = [System.Windows.Visibility]::Visible } } else { $appDetailEmpty.Visibility = [System.Windows.Visibility]::Visible $appDetailContent.Visibility = [System.Windows.Visibility]::Collapsed $appDetailLoading.Visibility = [System.Windows.Visibility]::Collapsed } }) $clearFiltersButton.add_Click({ if ($null -eq $syncHash.CurrentAppResults -or $syncHash.CurrentAppResults.Count -eq 0) { return } $filterProps = Get-FilterableProperties -AppResults $syncHash.CurrentAppResults New-FilterPanel -FilterProperties $filterProps -WrapPanel $filterWrapPanel -SyncHash $syncHash -OnChangeCallback { Invoke-FilterUpdate -SyncHash $syncHash } Invoke-FilterUpdate -SyncHash $syncHash }) $exportCsvButton.add_Click({ $selectedApp = $appsComboBox.SelectedItem $items = @($syncHash.VersionsListView.Items) if ($null -eq $selectedApp -or $items.Count -eq 0) { Write-UILog -SyncHash $syncHash -Message 'No version data to export. Load an app first.' -Level Warning return } $dlg = New-Object Microsoft.Win32.SaveFileDialog $dlg.Title = 'Export to CSV' $dlg.FileName = "$($selectedApp.Name).csv" $dlg.DefaultExt = '.csv' $dlg.Filter = 'CSV Files (*.csv)|*.csv|All Files (*.*)|*.*' if ($dlg.ShowDialog() -eq $true) { try { $items | Export-Csv -Path $dlg.FileName -NoTypeInformation -Encoding UTF8 Write-UILog -SyncHash $syncHash -Message "Exported $($items.Count) rows to $($dlg.FileName)" -Level Info } catch { Write-UILog -SyncHash $syncHash -Message "Export failed: $_" -Level Error } } }) $addToQueueButton.add_Click({ $selectedApp = $appsComboBox.SelectedItem $selectedVersions = @($syncHash.VersionsListView.SelectedItems) if ($null -eq $selectedApp -or $selectedVersions.Count -eq 0) { Write-UILog -SyncHash $syncHash -Message 'Select one or more version rows before adding to queue.' -Level Warning return } foreach ($selectedVersion in $selectedVersions) { $queueItem = [PSCustomObject]@{ AppName = [string]$selectedApp.Name Version = [string]$selectedVersion.Version Platform = if ($selectedVersion.PSObject.Properties.Name -contains 'Platform') { [string]$selectedVersion.Platform } else { '' } Architecture = if ($selectedVersion.PSObject.Properties.Name -contains 'Architecture') { [string]$selectedVersion.Architecture } else { '' } Channel = if ($selectedVersion.PSObject.Properties.Name -contains 'Channel') { [string]$selectedVersion.Channel } else { '' } Uri = if ($selectedVersion.PSObject.Properties.Name -contains 'URI') { [string]$selectedVersion.URI } else { '' } Status = 'Pending' } $isDuplicate = $syncHash.DownloadQueue | Where-Object { $_.AppName -eq $queueItem.AppName -and $_.Version -eq $queueItem.Version -and $_.Architecture -eq $queueItem.Architecture -and $_.Channel -eq $queueItem.Channel -and $_.Platform -eq $queueItem.Platform } if ($isDuplicate) { Write-UILog -SyncHash $syncHash -Message "Already queued: $($queueItem.AppName) $($queueItem.Version)" -Level Warning continue } $syncHash.DownloadQueue.Add($queueItem) Write-UILog -SyncHash $syncHash -Message "Queued: $($queueItem.AppName) $($queueItem.Version)" -Level Info } & $refreshQueueView }) $removeQueueItemButton.add_Click({ if ($syncHash.IsRunning) { Write-UILog -SyncHash $syncHash -Message 'Cannot remove queue items while downloads are running.' -Level Warning return } $selectedQueueItem = $syncHash.DownloadQueueListView.SelectedItem if ($null -eq $selectedQueueItem) { Write-UILog -SyncHash $syncHash -Message 'Select one queue item to remove.' -Level Warning return } [void]$syncHash.DownloadQueue.Remove($selectedQueueItem) Write-UILog -SyncHash $syncHash -Message 'Removed selected item from queue.' -Level Info & $refreshQueueView }) $clearQueueButton.add_Click({ if ($syncHash.IsRunning) { Write-UILog -SyncHash $syncHash -Message 'Cannot clear queue while downloads are running.' -Level Warning return } $syncHash.DownloadQueue.Clear() Write-UILog -SyncHash $syncHash -Message 'Queue cleared.' -Level Info & $refreshQueueView }) $openDownloadFolderButton.add_Click({ $folderPath = $syncHash.Config.OutputPath if ([string]::IsNullOrWhiteSpace($folderPath)) { Write-UILog -SyncHash $syncHash -Message 'No download output path configured.' -Level Warning return } if (-not (Test-Path -LiteralPath $folderPath)) { $null = New-Item -ItemType Directory -Path $folderPath -Force } Start-Process -FilePath 'explorer.exe' -ArgumentList $folderPath | Out-Null }) $syncHash.DownloadAllButton.add_Click({ & $startQueueDownload }) $libraryRefreshButton.add_Click({ & $refreshLibraryView }) $libraryBrowseButton.add_Click({ $dlg = [System.Windows.Forms.FolderBrowserDialog]::new() $dlg.Description = 'Select Evergreen library folder' $dlg.SelectedPath = $libraryPathViewBox.Text if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $libraryPathViewBox.Text = $dlg.SelectedPath $syncHash.Config.LibraryPath = $dlg.SelectedPath Set-UIConfig -Config $syncHash.Config & $refreshLibraryView } }) $libraryNewButton.add_Click({ $path = $libraryPathViewBox.Text if ([string]::IsNullOrWhiteSpace($path)) { Write-UILog -SyncHash $syncHash -Message 'Set a library path before creating a new library.' -Level Warning return } try { Write-UILog -SyncHash $syncHash -Message "New-EvergreenLibrary -Path '$path'" -Level Cmd New-EvergreenLibrary -Path $path -ErrorAction Stop | Out-Null Write-UILog -SyncHash $syncHash -Message "Created Evergreen library: $path" -Level Info $syncHash.Config.LibraryPath = $path Set-UIConfig -Config $syncHash.Config & $refreshLibraryView } catch { Write-UILog -SyncHash $syncHash -Message "Failed to create library: $_" -Level Error } }) $libraryOpenFolderButton.add_Click({ $path = $libraryPathViewBox.Text if ([string]::IsNullOrWhiteSpace($path)) { return } if (Test-Path -LiteralPath $path -PathType Container) { Start-Process -FilePath 'explorer.exe' -ArgumentList $path | Out-Null } else { Write-UILog -SyncHash $syncHash -Message "Library path does not exist: $path" -Level Warning } }) $syncHash.LibraryUpdateButton.add_Click({ & $startLibraryUpdate }) $syncHash.LibraryContentsListView.add_MouseDoubleClick({ $selected = $syncHash.LibraryContentsListView.SelectedItem & $loadLibraryAppDetails -SelectedLibraryItem $selected }) $syncHash.LibraryContentsListView.add_SelectionChanged({ $selected = $syncHash.LibraryContentsListView.SelectedItem if ($null -eq $selected) { $syncHash.LibraryDetailsListView.ItemsSource = @() return } & $loadLibraryAppDetails -SelectedLibraryItem $selected }) $libraryPathViewBox.add_LostFocus({ $normalised = & $normalizeDirectoryPath -PathValue $libraryPathViewBox.Text $libraryPathViewBox.Text = $normalised $syncHash.Config.LibraryPath = $normalised Set-UIConfig -Config $syncHash.Config }) $logVerbosityComboBox.add_SelectionChanged({ $item = $logVerbosityComboBox.SelectedItem if ($null -eq $item) { return } $syncHash.Config.LogVerbosity = [string]$item.Content Set-UIConfig -Config $syncHash.Config }) $themeComboBox.add_SelectionChanged({ $item = $themeComboBox.SelectedItem if ($null -eq $item) { return } if ([string]$item.Content -eq 'Dark') { Set-DarkTheme -Window $syncHash.Window $syncHash.Config.Theme = 'Dark' } else { Set-LightTheme -Window $syncHash.Window $syncHash.Config.Theme = 'Light' } Set-UIConfig -Config $syncHash.Config }) $startupViewComboBox.add_SelectionChanged({ $item = $startupViewComboBox.SelectedItem if ($null -eq $item) { return } $selected = [string]$item.Content if ([string]::IsNullOrWhiteSpace($selected)) { $selected = 'Apps' } $syncHash.Config.StartupView = $selected Set-UIConfig -Config $syncHash.Config }) # Navigation: Settings panel - populate form on activation $navSettings.add_Checked({ $outputPathBox.Text = $syncHash.Config.OutputPath $evergreenAppsPathBox.Text = (Get-EvergreenAppsPath) $desiredVerbosity = [string]$syncHash.Config.LogVerbosity $logVerbosityComboBox.SelectedIndex = if ($desiredVerbosity -eq 'Verbose') { 1 } else { 0 } $themeComboBox.SelectedIndex = if ([string]$syncHash.Config.Theme -eq 'Dark') { 1 } else { 0 } switch ([string]$syncHash.Config.StartupView) { 'Download' { $startupViewComboBox.SelectedIndex = 1 } 'Library' { $startupViewComboBox.SelectedIndex = 2 } 'Settings' { $startupViewComboBox.SelectedIndex = 3 } default { $startupViewComboBox.SelectedIndex = 0 } } }) # Log panel collapse / expand # When expanded, the log area height (above the 32px status bar) is restored # from config; when collapsed, row 3 drops to exactly the status bar height. $logToggleButton.add_Click({ if ($logToggleButton.IsChecked) { $restoreHeight = [Math]::Max(80, $syncHash.Config.LogHeight) $logRowDef.Height = [System.Windows.GridLength]::new(40 + $restoreHeight) $logToggleButton.Content = 'Hide progress log' $syncHash.Config.LogVisible = $true } else { # Save current displayed log height before collapsing $currentHeight = [int]$logRowDef.Height.Value - 40 if ($currentHeight -gt 0) { $syncHash.Config.LogHeight = $currentHeight } $logRowDef.Height = [System.Windows.GridLength]::new(40) $logToggleButton.Content = 'Show progress log' $syncHash.Config.LogVisible = $false } Set-UIConfig -Config $syncHash.Config }) # Copy log $copyLogButton.add_Click({ if (-not [string]::IsNullOrEmpty($syncHash.LogTextBox.Text)) { [System.Windows.Clipboard]::SetText($syncHash.LogTextBox.Text) Write-UILog -SyncHash $syncHash -Message 'Log copied to clipboard.' -Level Info } }) # Save log $saveLogButton.add_Click({ $dlg = [System.Windows.Forms.SaveFileDialog]::new() $dlg.Filter = 'Text files (*.txt)|*.txt|All files (*.*)|*.*' $dlg.FileName = "EvergreenUI-Log-$(Get-Date -Format 'yyyyMMdd-HHmmss').txt" if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { try { $syncHash.LogTextBox.Text | Set-Content -Path $dlg.FileName -Encoding UTF8 -ErrorAction Stop Write-UILog -SyncHash $syncHash -Message "Log saved: $($dlg.FileName)" -Level Info } catch { Write-UILog -SyncHash $syncHash -Message "Failed to save log: $_" -Level Error } } }) # Settings: Output path - Browse $browseOutputButton.add_Click({ $dlg = [System.Windows.Forms.FolderBrowserDialog]::new() $dlg.Description = 'Select download output folder' $dlg.SelectedPath = $outputPathBox.Text if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $normalised = & $normalizeDirectoryPath -PathValue $dlg.SelectedPath $outputPathBox.Text = $normalised $syncHash.Config.OutputPath = $normalised Set-UIConfig -Config $syncHash.Config } }) # Settings: Open cache folder $openEvergreenAppsFolderButton.add_Click({ $folderPath = $evergreenAppsPathBox.Text if ([string]::IsNullOrWhiteSpace($folderPath)) { return } if (-not (Test-Path -LiteralPath $folderPath)) { $null = New-Item -ItemType Directory -Path $folderPath -Force } Start-Process -FilePath 'explorer.exe' -ArgumentList $folderPath | Out-Null }) # Settings: Open cache folder $openCacheFolderButton.add_Click({ $cacheDir = Join-Path $env:APPDATA 'EvergreenUI\cache' if (-not (Test-Path -LiteralPath $cacheDir)) { $null = New-Item -ItemType Directory -Path $cacheDir -Force } Start-Process -FilePath 'explorer.exe' -ArgumentList $cacheDir | Out-Null }) # Settings: Clear cache $clearCacheButton.add_Click({ $cacheDir = Join-Path $env:APPDATA 'EvergreenUI\cache' if (Test-Path -LiteralPath $cacheDir) { try { $files = Get-ChildItem -LiteralPath $cacheDir -Filter '*.json' -File -ErrorAction Stop $count = $files.Count $files | Remove-Item -Force -ErrorAction Stop Write-UILog -SyncHash $syncHash -Message "Cache cleared. $count file(s) removed." -Level Info } catch { Write-UILog -SyncHash $syncHash -Message "Failed to clear cache: $_" -Level Error } } else { Write-UILog -SyncHash $syncHash -Message 'Cache directory does not exist. Nothing to clear.' -Level Info } }) # Settings: persist path edits on focus-leave $outputPathBox.add_LostFocus({ $normalised = & $normalizeDirectoryPath -PathValue $outputPathBox.Text $outputPathBox.Text = $normalised $syncHash.Config.OutputPath = $normalised Set-UIConfig -Config $syncHash.Config }) # Show window (blocking) [void]$window.ShowDialog() |