Terminal/SRxTerminalBoxControl.psm1
|
<#
SRxTerminalBoxControl.psm1 (Terminal subsystem — Iteration 10.1: Monaco Editor tabs + read-only) ------------------------------------------------------------------------ Self-contained WinForms UserControl hosting a WebView2 (xterm.js) surface wired to a ConPTY-backed PowerShell child process (SRxConPty.psm1). MECHANICS (same convention as SRxPropertyBoxControl / SRxFilterBoxControl): - Host uses this by COMPOSITION + DYNAMIC DISPATCH (untyped field on the Explorer form). No 'using module'. - WinForms/Drawing must be loaded BEFORE this module is imported (same requirement as the other *Box controls). - Create via factory New-SRxTerminalBoxControl. - Import the logic modules (SRxConPty.psm1) INSIDE this module (scope isolation), exactly like SRxFilterBoxControl imports SRxFilterBox.psm1. ------------------------------------------------------------------------ GOTCHA #1: PowerShell classes are compiled at PARSE TIME ------------------------------------------------------------------------ ANY `[Namespace.TypeName]` bracket-literal written ANYWHERE inside the `class SRxTerminalBoxControl { ... }` body must already be resolvable the instant this module is Import-Module'd — even if buried in a method that is never called. Since WebView2's assemblies are only Add-Type'd lazily (the first time the panel opens - see Import-SRxWebView2Assemblies), any `[Microsoft.Web.WebView2.*]` bracket-literal anywhere in this class would make Import-Module fail immediately with "Unable to find type [...]". FIX: never write such a literal in this class. Use New-Object -TypeName '<string>' to construct instances, and resolve static types/methods/enums via reflection helpers (Get-SRxWebView2Type below) instead of bracket-literals. ------------------------------------------------------------------------ GOTCHA #2: CoreWebView2EnvironmentOptions has no New-Object-friendly ctor ------------------------------------------------------------------------ `New-Object -TypeName '...CoreWebView2EnvironmentOptions'` fails on some SDK builds ("A constructor was not found"). CreateAsync's 3rd parameter is optional/nullable per Microsoft's own docs/samples - we just pass $null (use-default-options) instead of constructing this object at all. ------------------------------------------------------------------------ GOTCHA #3: [type]::GetType('Name, SimpleAssembly') can't see LoadFrom context assemblies ------------------------------------------------------------------------ `Add-Type -Path <dll>` loads the assembly into .NET's "LoadFrom" binding context. `[type]::GetType('TypeName, SimpleAssemblyName')` internally calls `Assembly.Load(SimpleAssemblyName)`, which only searches the "Load" context - it does NOT see LoadFrom-context assemblies, even though they ARE fully loaded in the AppDomain. FIX: Get-SRxWebView2Type enumerates [AppDomain]::CurrentDomain.GetAssemblies() directly and calls .GetType(fullName) on the matching Assembly object. ------------------------------------------------------------------------ GOTCHA #4: PowerShell scriptblock-as-delegate invoked from a background .NET thread can silently hang/fail ------------------------------------------------------------------------ A PowerShell scriptblock cast to a .NET delegate (e.g. `[Action]{...}`) executes via ScriptBlock.Invoke() internally, which requires [Runspace]::DefaultRunspace to be set on the CALLING thread. Threads with no PowerShell runspace attached (a raw ThreadPool worker thread, or a plain background System.Threading.Thread) cannot reliably invoke such a delegate - the failure can be silent rather than a visible exception. FIX: never subscribe a PowerShell scriptblock directly to an event or Task continuation that fires on a background .NET thread. Instead, use a System.Windows.Forms.Timer that POLLS for completion/new data. Timer.Tick ALWAYS fires on the UI thread's own message loop. - CoreWebView2Environment.CreateAsync's Task is polled via _startupTimer (checks $task.IsCompleted). - ConPTY output/exit is polled via _ioTimer, draining $this._conPty.TryDequeueOutput(...) and checking $this._conPty.HasExited. ------------------------------------------------------------------------ GOTCHA #5: CoreWebView2 (unlike the WinForms WebView2 Control and unlike System.Windows.Forms.Timer) has NO .Tag property at all ------------------------------------------------------------------------ `$this._webView` (the WinForms CONTROL) inherits System.Windows.Forms. Control and has .Tag. `$this._startupTimer`/`$this._ioTimer` also have their own .Tag (Timer defines it directly). But `$this._webView.CoreWebView2` is a plain COM-wrapper class with NO .Tag property - setting it throws "The property 'Tag' cannot be found on this object". FIX: capture $control directly via a `.GetNewClosure()` scriptblock instead of round-tripping through a (non-existent) .Tag property on $core. ------------------------------------------------------------------------ Header construction (close button / title) DELIBERATELY MIRRORS SRxPropertyBoxControl's own ToolStrip-based header exactly (confirmed against that module's real source): a System.Windows.Forms.ToolStrip (auto-sizing height, ImageScalingSize=(20,20) controls the RENDERED icon size) hosting a ToolStripLabel (title) + ToolStripButton (close, 24x24, DisplayStyle=Image, ImageScaling=SizeToFit) - NOT a plain Panel+Button. ------------------------------------------------------------------------ ------------------------------------------------------------------------ ITERATION 7 — RunCommand(): programmatic input from the Explorer host ------------------------------------------------------------------------ Added to support Explorer's "Enable Terminal" redirect (onBeginCommand()): lets the HOST type a command into the terminal's already-running interactive shell.ps1 session, exactly as if the user had typed it. Since the ConPTY session may not exist yet the very first time this is called (WebView2/environment creation is still asynchronously in flight via _startupTimer, and the ConPTY session itself is only created once the xterm.js page signals 'ready' - see onWebMessage), pending text is queued in $_pendingCommands (a plain List[string], only ever touched from the UI thread - both RunCommand() and the flush point in startConPty() run on the UI thread, so no additional synchronization is needed) and flushed as soon as $this._conPty is assigned. ------------------------------------------------------------------------ ITERATION 8 — RestartSession(): relaunch the ConPTY session in place, WITHOUT tearing down WebView2/xterm.js ------------------------------------------------------------------------ Added to support Explorer's "Enable Debug" toggle: when that value changes, any ALREADY-RUNNING Terminal session was started with the OLD -EnableDebug (and -RulesLibPath) flags baked into its StartupCommandLine - since each child powershell.exe process gets its own fresh $global:SRxEnv, there is no way for an already-running session to pick up the new value on its own. RestartSession() lets the host say "kill the current shell.ps1 process and start a fresh one using whatever StartupCommandLine is CURRENTLY set" - the host is expected to update .StartupCommandLine (via a freshly-computed value reflecting the new EnableDebug/RulesLibPath state) immediately BEFORE calling this method. Deliberately does NOT touch $this._webView, $this._startupTimer, or CoreWebView2 at all - WebView2/xterm.js stay fully alive and connected; only the ConPTY session + its I/O pump timer are torn down and recreated. This is both cheaper (no WebView2Environment re-creation, no re-navigate) and gives a smoother visual result than a full Shutdown()+Activate() cycle would. ------------------------------------------------------------------------ ITERATION 9 — RestartSession() BUGFIX: race with Activate()'s own first-time async startup chain caused a "black screen" Terminal (confirmed via live testing) ------------------------------------------------------------------------ CONFIRMED BUG: RestartSession()'s guard "if (-not $this._started) { return }" does NOT protect against the case where Activate() has JUST been called for the FIRST TIME and is still asynchronously in flight (WebView2 environment creation / CoreWebView2 init / Navigate() / the xterm.js page's own load+ready handshake can easily take several hundred ms to a few seconds) - because $this._started is set to $true as the VERY FIRST LINE of Activate(), long before any of that async work actually completes. If Explorer's "Enable Debug" toggle handler happens to call RestartSession() DURING that window (e.g. the async Rules Library refresh finishes around the same time the user opens the Terminal panel for the first time), RestartSession() saw $this._conPty as $null (Activate() hadn't reached startConPty() yet) and then called $this.startConPty(...) ITSELF, unconditionally, at its own tail - creating a REAL ConPTY-backed process EARLY, before WebView2/CoreWebView2 even existed yet. pumpConPtyIo()'s own I/O timer then started polling immediately (startIoPump() runs from inside that premature startConPty() call) - and since TryDequeueOutput() removes each chunk from the queue REGARDLESS of whether "$this._webView -and $this._webView.CoreWebView2" is true, every early output chunk the shell prints on startup (its banner + first prompt) got silently DISCARDED, because CoreWebView2 wasn't ready yet at that moment. By the time WebView2/xterm.js finally finished loading and sent their OWN {t:'ready'} signal, onWebMessage's call to startConPty() just no-op'd ("if ($this._conPty) { return }" - a session already existed from the premature call) - so the now-ready terminal UI never received ANY of that already-printed, already- discarded output. The process stayed alive and the app stayed responsive (hence a real PID, hence no crash) - it was just idly waiting for input into a UI that had never actually seen its own startup banner: a live process, a silent/black terminal. THE FIX: RestartSession() now only force-starts a NEW ConPTY session if one ALREADY EXISTED at the moment it was called. If none existed yet, that means Activate()'s own first-time async chain is still in flight - the correct behavior is to leave the just-updated StartupCommandLine in place and let that NATURAL onWebMessage('ready') path create the session itself, once xterm.js is genuinely ready to receive output - exactly the sequencing that already worked correctly before Iterations 4/5 introduced Rules-Library-triggered RestartSession() calls that can now race against a control's very first activation. ------------------------------------------------------------------------ ITERATION 10 - Monaco Editor tabs ------------------------------------------------------------------------ The control now hosts TWO views in one content area: - the Terminal (unchanged: WebView2 + xterm.js + ConPTY), and - a Monaco editor surface: ONE extra WebView2 (www/editor.html) with ONE Monaco instance and ONE model per open file. Tabs swap models, so ten open files do not cost ten Chromium renderers. Header strip: [Save*] Terminal [x file1] [x file2] ... [X] - "Terminal" label -> shows the terminal view - tab -> left-aligned cross = Editor.Close(), title = file name (a dot is appended while dirty), click = select, middle-click = close - Save button (left) -> visible only while the SELECTED editor tab has unsaved changes; wired to Editor.Save() Public API: $ctl.Editors.Open('C:\abs\path\file.ps1') -> editor (or $null + .LastError) $editor.Save() / $editor.Close() / $editor.Close($true) / $editor.Select() $ctl.Editors.Open('C:\abs\file.ps1', $true) -> read-only editor (Iteration 10.1) $editor.SetReadOnly($bool) $ctl.OpenEditor($path [, $readOnly]) $ctl.ShowTerminal() $ctl.HasUnsavedEditors() Close() with unsaved changes shows the same Save / Discard / Cancel overlay as SRxPropertyBoxControl. Both WebView2 controls share ONE CoreWebView2Environment (acquireEnvironment): one CreateAsync, polled by _startupTimer (GOTCHA #4), handed to each waiting consumer ('terminal' / 'editor'). The terminal's own startup sequencing (Iteration 9) is otherwise unchanged. Editor JSON messages use ConvertTo-/ConvertFrom-SRxJsonMessage (JavaScriptSerializer, no 2MB limit) because whole files travel through them. First editor activation also runs the VS Code check (SRxVSCode.psm1): installed -> nothing; missing -> hidden current-user install, polled by _vscodeTimer. Disable with $ctl.EnsureVSCode = $false. ------------------------------------------------------------------------ ITERATION 10.1 - tab typography + read-only editors ------------------------------------------------------------------------ 1) Tab title uses the SAME Font object as the "Terminal" ToolStripLabel (the strip's font) instead of a separately constructed 'Segoe UI 9' (which rendered visibly larger under DPI scaling), and the tab occupies exactly the same vertical slot as that label: tab height = strip DisplayRectangle height minus the label's own vertical Margin, host Margin top/bottom = label Margin, title Label fills the full tab height with TextAlign MiddleLeft -> both texts share one baseline. 2) $ctl.Editors.Open($path, $true|$false) / $ctl.OpenEditor($path, $ro) / $editor.SetReadOnly($ro). Read-only editors: Monaco readOnly + domReadOnly (no typing, paste, cut, drop, IME, undo/redo; selection, copy, find and scrolling still work), Ctrl+S suppressed in the page, and the Save button is guarded in updateSaveButton() and onSaveButtonClick() - it can never show for a read-only tab. ------------------------------------------------------------------------ #> Import-Module (Join-Path $PSScriptRoot 'SRxConPty.psm1') -DisableNameChecking -Force -ErrorAction SilentlyContinue #begin Monaco Editor - Iteration 10 Import-Module (Join-Path $PSScriptRoot 'SRxMonacoEditor.psm1') -DisableNameChecking -Force -ErrorAction Stop Import-Module (Join-Path $PSScriptRoot 'SRxVSCode.psm1') -DisableNameChecking -Force -ErrorAction Stop #end Monaco Editor - Iteration 10 #region ---- WebView2 SDK bootstrap (lazy, tolerant of missing deployment) -- function Get-SRxLoadedAssembly { <# .SYNOPSIS Finds an already-loaded assembly by its simple name, by enumerating the current AppDomain directly (works regardless of Load/LoadFrom binding context - see GOTCHA #3 above). #> [CmdletBinding()] param([Parameter(Mandatory)][string]$SimpleName) return [System.AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq $SimpleName } | Select-Object -First 1 } function Import-SRxWebView2Assemblies { <# .SYNOPSIS Loads the Microsoft.Web.WebView2 Core/WinForms assemblies from <ModuleRoot>\Lib\WebView2 (idempotent). Returns $true on success. .NOTES The native WebView2Loader.dll (matching process bitness) must live in the SAME folder as these managed dlls, or somewhere on the process's native DLL search path. If the DLLs were downloaded via a browser, Windows Mark-of-the-Web will block loading them (HRESULT 0x80131515) until you run Unblock-File on all three. #> [CmdletBinding()] param( [string]$LibPath = (Join-Path $PSScriptRoot 'Lib\WebView2') ) if (Get-SRxLoadedAssembly 'Microsoft.Web.WebView2.WinForms') { return $true } try { $core = Join-Path $LibPath 'Microsoft.Web.WebView2.Core.dll' $winf = Join-Path $LibPath 'Microsoft.Web.WebView2.WinForms.dll' if (-not (Test-Path $core) -or -not (Test-Path $winf)) { Write-Host "Import-SRxWebView2Assemblies - SDK assemblies not found under '$LibPath'." return $false } Add-Type -Path $core -ErrorAction Stop Add-Type -Path $winf -ErrorAction Stop return $true } catch { Write-Host "Import-SRxWebView2Assemblies - $($_.Exception.Message)" return $false } } function Get-SRxWebView2Type { <# .SYNOPSIS Resolves a Microsoft.Web.WebView2.* type by its short name. Never via a bracket-literal — safe to call from inside the SRxTerminalBoxControl CLASS body (see GOTCHA #1). .PARAMETER ShortName e.g. 'Core.CoreWebView2Environment' or 'Core.CoreWebView2HostResourceAccessKind' or 'WinForms.WebView2' #> [CmdletBinding()] param([Parameter(Mandatory)][string]$ShortName) $assemblySimpleName = if ($ShortName -like 'WinForms.*') { 'Microsoft.Web.WebView2.WinForms' } else { 'Microsoft.Web.WebView2.Core' } $fullTypeName = "Microsoft.Web.WebView2.$ShortName" $asm = Get-SRxLoadedAssembly $assemblySimpleName if ($asm) { $t = $asm.GetType($fullTypeName) if ($t) { return $t } } try { return [type]::GetType("$fullTypeName, $assemblySimpleName") } catch { return $null } } #endregion class SRxTerminalBoxControl : System.Windows.Forms.UserControl { #-------------------------------------- # public configuration surface (mirrors SRxPropertyBoxControl conventions) #-------------------------------------- [string]$SourcePath = "" [hashtable]$Icons = @{} [System.Management.Automation.ScriptBlock]$OnStatus = $null # param($text) [System.Management.Automation.ScriptBlock]$OnClose = $null # param() -- host unchecks its toggle button [string]$StartupCommandLine = "powershell.exe -NoLogo -NoExit" #begin Monaco Editor - Iteration 10 [object]$Editors = $null # SRxMonacoEditorCollection (Open/Get/Items/...) [string]$EditorTheme = 'vs-dark' [bool]$EnsureVSCode = $true # first editor activation: detect / install VS Code [string]$VSCodeBuildEdition = 'Stable-User' [bool]$DisableEditorSurface = $false # tests: no WebView2 for the editor (messages stay queued) [System.Management.Automation.ScriptBlock]$OnRequestShow = $null # param() - host makes the panel visible #end Monaco Editor - Iteration 10 #-------------------------------------- # internal state (kept UNTYPED on purpose — see GOTCHA #1 above: none of # these may ever be declared as [Microsoft.Web.WebView2.*] or # [SRx.Terminal.ConPtySession], or Import-Module fails immediately) #-------------------------------------- hidden $_headerPanel = $null # System.Windows.Forms.ToolStrip hidden $_titleLabel = $null # System.Windows.Forms.ToolStripLabel hidden $_closeButton = $null # System.Windows.Forms.ToolStripButton hidden $_placeholder = $null # plain Label shown when WebView2/ConPTY unavailable hidden $_webView = $null # Microsoft.Web.WebView2.WinForms.WebView2 (dynamic) hidden $_conPty = $null # SRx.Terminal.ConPtySession (dynamic) hidden $_startupTimer = $null # System.Windows.Forms.Timer - polls CreateAsync's Task (GOTCHA #4) hidden $_ioTimer = $null # System.Windows.Forms.Timer - polls ConPTY output queue + exit (GOTCHA #4) hidden $_pendingCommands = $null # System.Collections.Generic.List[string] - queued RunCommand() text (Iteration 7) hidden [bool]$_started = $false hidden [bool]$_webViewReady = $false hidden [int]$_lastCols = 80 hidden [int]$_lastRows = 24 #begin Monaco Editor - Iteration 10 hidden $_contentPanel = $null # Panel (Fill) hosting both views hidden $_terminalPanel = $null # Panel (Fill): terminal WebView2 + _placeholder hidden $_editorPanel = $null # Panel (Fill): editor WebView2 + placeholder + overlay hidden $_editorWebView = $null # Microsoft.Web.WebView2.WinForms.WebView2 (dynamic) hidden $_editorPlaceholder = $null # Label hidden $_btnSave = $null # ToolStripButton (left) - selected editor's Save() hidden $_tabs = $null # hashtable editorId -> @{ Host; Panel; Close; Label; Editor } hidden [string]$_activeView = 'terminal' # 'terminal' or an editor Id hidden [bool]$_editorStarted = $false hidden [bool]$_editorJsReady = $false hidden $_editorOutbox = $null # List[string] - JSON queued until editor.html is 'ready' hidden $_overlay = $null # unsaved-changes prompt (mirrors SRxPropertyBoxControl) hidden $_overlayMsg = $null hidden $_overlaySave = $null hidden $_overlayDiscard = $null hidden $_overlayCancel = $null hidden $_promptEditor = $null # editor the overlay is asking about # shared CoreWebView2Environment hidden $_environment = $null hidden $_envTask = $null hidden [string]$_envError = '' hidden $_envWaiters = $null # List[string] 'terminal' | 'editor' # VS Code provisioning hidden [bool]$_vscodeChecked = $false hidden $_vscodeInstall = $null hidden $_vscodeTimer = $null hidden $_tabToolTip = $null # ToolTip (full path on tabs) hidden $_crossBitmap = $null # fallback tab close pictogram #end Monaco Editor - Iteration 10 SRxTerminalBoxControl() : base() { $this._pendingCommands = New-Object 'System.Collections.Generic.List[string]' #begin Monaco Editor - Iteration 10 $this._editorOutbox = New-Object 'System.Collections.Generic.List[string]' $this._envWaiters = New-Object 'System.Collections.Generic.List[string]' $this._tabs = @{} $this.Editors = New-SRxMonacoEditorCollection -Owner $this #end Monaco Editor - Iteration 10 $this.buildLayout() } #-------------------------------------- # Header construction DELIBERATELY MIRRORS SRxPropertyBoxControl's own # ToolStrip-based header exactly (see module header comment above for # the full rationale / confirmed property values). #-------------------------------------- hidden [void] buildLayout() { $this.Dock = [System.Windows.Forms.DockStyle]::Fill $this._headerPanel = [System.Windows.Forms.ToolStrip]::new() $this._headerPanel.Dock = [System.Windows.Forms.DockStyle]::Top $this._headerPanel.GripStyle = [System.Windows.Forms.ToolStripGripStyle]::Hidden $this._headerPanel.BackColor = [System.Drawing.SystemColors]::ControlLight $this._headerPanel.ImageScalingSize = [System.Drawing.Size]::new(20, 20) # Cosmetic fix (supersedes the original "Height intentionally NOT # set" design note above): match the fixed 35px header-bar height # used by the left-hand tree panel's own top bar (Explorer.ps1's # _toolStripTree, hosted in a TableLayoutPanel row with # RowStyles.Item(0).Height=35). Left auto-sized, this header # rendered visibly shorter than that reference bar, once compared # side-by-side - Property Box's _title header gets this same fix. $this._headerPanel.AutoSize = $false $this._headerPanel.Height = 35 #begin Monaco Editor - Iteration 10 # Save button: same construction as SRxPropertyBoxControl._btnSave $this._btnSave = [System.Windows.Forms.ToolStripButton]::new() $this._btnSave.Name = '_btnSave' $this._btnSave.DisplayStyle = [System.Windows.Forms.ToolStripItemDisplayStyle]::Image $this._btnSave.Alignment = [System.Windows.Forms.ToolStripItemAlignment]::Left #$this._btnSave.AutoSize = $false $this._btnSave.Size = [System.Drawing.Size]::new(29, 24) $this._btnSave.ImageScaling = [System.Windows.Forms.ToolStripItemImageScaling]::SizeToFit $this._btnSave.AutoToolTip = $false $this._btnSave.ToolTipText = '' $this._btnSave.Enabled = $false $this._btnSave.Visible = $false $this._btnSave.Tag = $this $this._btnSave.add_Click({ param($s, $e) $s.Tag.onSaveButtonClick() }) $this._btnSave.add_MouseEnter({ param($s, $e) $s.Tag.onSaveButtonMouseEnter() }) $this._btnSave.add_MouseLeave({ param($s, $e) $s.Tag.onSaveButtonMouseLeave() }) #end Monaco Editor - Iteration 10 $this._titleLabel = [System.Windows.Forms.ToolStripLabel]::new('Terminal') #begin Monaco Editor - Iteration 10 $this._titleLabel.Tag = $this $this._titleLabel.add_Click({ param($s, $e) $s.Tag.ShowTerminal() }) #end Monaco Editor - Iteration 10 $this._closeButton = [System.Windows.Forms.ToolStripButton]::new() $this._closeButton.Alignment = [System.Windows.Forms.ToolStripItemAlignment]::Right $this._closeButton.DisplayStyle = [System.Windows.Forms.ToolStripItemDisplayStyle]::Image #$this._closeButton.AutoSize = $false # Cosmetic fix: widened from 24 to 29 to match the button sizing # convention used on the left-hand tree panel's own top bar # (Explorer.ps1's _buttonProperties/_buttonTerminal, both 29x24). $this._closeButton.Size = [System.Drawing.Size]::new(29, 24) $this._closeButton.ImageScaling = [System.Windows.Forms.ToolStripItemImageScaling]::SizeToFit $this._closeButton.AutoToolTip = $false $this._closeButton.Tag = $this $this._closeButton.add_Click({ param($source, $e) $_this = $source.Tag if ($_this.OnClose) { $_this.OnClose.Invoke() } }) [void]$this._headerPanel.Items.Add($this._btnSave) # Iteration 10 [void]$this._headerPanel.Items.Add($this._titleLabel) [void]$this._headerPanel.Items.Add($this._closeButton) $this._placeholder = [System.Windows.Forms.Label]::new() $this._placeholder.Dock = [System.Windows.Forms.DockStyle]::Fill $this._placeholder.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter $this._placeholder.Text = "Terminal is starting..." $this._placeholder.Visible = $false #begin Monaco Editor - Iteration 10 # content area: terminal view and editor view are sibling Fill panels; # switching views toggles their Visible flags. $this._contentPanel = [System.Windows.Forms.Panel]::new() $this._contentPanel.Dock = [System.Windows.Forms.DockStyle]::Fill $this._terminalPanel = [System.Windows.Forms.Panel]::new() $this._terminalPanel.Dock = [System.Windows.Forms.DockStyle]::Fill [void]$this._terminalPanel.Controls.Add($this._placeholder) $this._editorPanel = [System.Windows.Forms.Panel]::new() $this._editorPanel.Dock = [System.Windows.Forms.DockStyle]::Fill $this._editorPanel.BackColor = [System.Drawing.Color]::FromArgb(30, 30, 30) $this._editorPanel.Visible = $false $this._editorPlaceholder = [System.Windows.Forms.Label]::new() $this._editorPlaceholder.Dock = [System.Windows.Forms.DockStyle]::Fill $this._editorPlaceholder.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter $this._editorPlaceholder.ForeColor = [System.Drawing.Color]::FromArgb(200, 200, 200) $this._editorPlaceholder.Text = "Editor is starting..." $this._editorPlaceholder.Visible = $false [void]$this._editorPanel.Controls.Add($this._editorPlaceholder) $this.buildOverlay() [void]$this._contentPanel.Controls.Add($this._editorPanel) [void]$this._contentPanel.Controls.Add($this._terminalPanel) [void]$this.Controls.Add($this._contentPanel) #end Monaco Editor - Iteration 10 [void]$this.Controls.Add($this._headerPanel) } #-------------------------------------- # icons (Close 'X' image) - just assign the raw bitmap; the ToolStrip's # ImageScalingSize (set in buildLayout above) handles scaling the # rendered icon automatically, regardless of the source bitmap's native # resolution (e.g. the shared 30x30 icons8-multiply-30.png also used # elsewhere in Explorer.ps1). #-------------------------------------- [void] ApplyIcons() { try { if ($this.Icons.ContainsKey('Close') -and $this.Icons['Close']) { $this._closeButton.Image = $this.Icons['Close'] } #begin Monaco Editor - Iteration 10 if ($this.Icons.ContainsKey('Save') -and $this.Icons['Save']) { $this._btnSave.Image = $this.Icons['Save'] $this._overlaySave.Image = $this.Icons['Save'] } foreach ($t in @($this._tabs.Values)) { $this.applyTabIcon($t) } $this.updateSaveButton() #end Monaco Editor - Iteration 10 } catch { Write-Host "SRxTerminalBoxControl.ApplyIcons - $($_.Exception.Message)" } } hidden [void] status([string]$text) { try { if ($this.OnStatus) { $this.OnStatus.Invoke($text) } } catch { } } #-------------------------------------- # Activate() - call this each time the host makes the panel visible. # Lazily boots WebView2 + the ConPTY session on first activation; on # subsequent activations it just re-focuses the existing session. # Idempotent - safe to call repeatedly (RunCommand() below relies on this). #-------------------------------------- [void] Activate() { if ($this._started) { try { if ($this._webView) { $this._webView.Focus() } } catch { } return } $this._started = $true $this.status("Starting terminal...") if (-not (Test-SRxConPtySupported)) { $this.showPlaceholder("Terminal unavailable - this Windows build does not support ConPTY (needs 1809+).") $this.status("Terminal unavailable (ConPTY unsupported).") return } if (-not (Import-SRxWebView2Assemblies)) { $this.showPlaceholder("Terminal unavailable - WebView2 SDK assemblies are not deployed. See Terminal\Lib\WebView2.") $this.status("Terminal unavailable (WebView2 SDK missing).") return } try { # 'Microsoft.Web.WebView2.WinForms.WebView2' below is a plain # STRING argument to New-Object, not a [bracket-literal] cast - # only resolved at runtime. Safe inside the class body (GOTCHA #1). $this._webView = New-Object -TypeName 'Microsoft.Web.WebView2.WinForms.WebView2' $this._webView.Dock = [System.Windows.Forms.DockStyle]::Fill [void]$this._terminalPanel.Controls.Add($this._webView) # Iteration 10: was $this.Controls $this._webView.BringToFront() #begin Monaco Editor - Iteration 10 # CoreWebView2Environment creation moved to acquireEnvironment() # (shared with the editor WebView2). Same CreateAsync call, same # UI-thread Timer polling (GOTCHA #2 / #4), same failure text. $this.acquireEnvironment('terminal') #end Monaco Editor - Iteration 10 } catch { $this.showPlaceholder("Terminal failed to start: $($_.Exception.Message)") $this.status("Terminal start failed: $($_.Exception.Message)") } } hidden [void] clearStartupTimer() { $this._startupTimer = $null } #begin Monaco Editor - Iteration 10 #-------------------------------------- # acquireEnvironment() - ONE CoreWebView2Environment shared by the # terminal and the editor WebView2 controls. The first caller starts # CreateAsync; later callers just register as waiters. Completion is # polled on the UI thread (GOTCHA #4) and handed to every waiter. #-------------------------------------- hidden [void] acquireEnvironment([string]$consumer) { if ($this._environment) { $this.dispatchEnvironment($consumer); return } if ($this._envError) { $this.dispatchEnvironmentFailure($consumer, $this._envError); return } if (-not $this._envWaiters.Contains($consumer)) { $this._envWaiters.Add($consumer) } if ($this._envTask) { return } # already in flight try { $userDataFolder = Join-Path $(if ($this.SourcePath) { $this.SourcePath } else { $env:TEMP }) 'WebView2UserData' if (-not (Test-Path $userDataFolder)) { New-Item -ItemType Directory -Path $userDataFolder -Force | Out-Null } $envType = Get-SRxWebView2Type 'Core.CoreWebView2Environment' if (-not $envType) { $this.failEnvironment("CoreWebView2Environment type not found after loading WebView2 SDK.") return } # 3rd arg is $null (GOTCHA #2) $this._envTask = $envType::CreateAsync($null, $userDataFolder, $null) $this._startupTimer = New-Object System.Windows.Forms.Timer $this._startupTimer.Interval = 50 $this._startupTimer.Tag = $this $this._startupTimer.add_Tick({ param($source, $e) $ctrl = $source.Tag $t = $ctrl._envTask if ($null -eq $t -or -not $t.IsCompleted) { return } $source.Stop() $source.Dispose() $ctrl.clearStartupTimer() if ($t.IsFaulted -or -not $t.Result) { $detail = "" try { if ($t.Exception) { $detail = " ($($t.Exception.GetBaseException().Message))" } } catch { } $ctrl.failEnvironment("WebView2 runtime not found or failed to initialize$detail. Install the Evergreen WebView2 Runtime.") return } $ctrl.environmentReady($t.Result) }) $this._startupTimer.Start() } catch { $this.failEnvironment("WebView2 environment creation failed: $($_.Exception.Message)") } } hidden [void] environmentReady($environment) { $this._environment = $environment $waiters = @($this._envWaiters.ToArray()) $this._envWaiters.Clear() foreach ($w in $waiters) { $this.dispatchEnvironment($w) } } hidden [void] failEnvironment([string]$reason) { $this._envError = $reason $waiters = @($this._envWaiters.ToArray()) $this._envWaiters.Clear() foreach ($w in $waiters) { $this.dispatchEnvironmentFailure($w, $reason) } } hidden [void] dispatchEnvironment([string]$consumer) { if ($consumer -eq 'terminal') { $this.onEnvironmentReady($this._environment) } elseif ($consumer -eq 'editor') { $this.onEditorEnvironmentReady($this._environment) } } hidden [void] dispatchEnvironmentFailure([string]$consumer, [string]$reason) { if ($consumer -eq 'terminal') { $this.showPlaceholder("Terminal unavailable - $reason") $this.status("Terminal unavailable.") } elseif ($consumer -eq 'editor') { $this.showEditorPlaceholder("Editor unavailable - $reason") $this.status("Editor unavailable.") } } #end Monaco Editor - Iteration 10 hidden [void] onEnvironmentReady($environment) { try { $this._webView.add_CoreWebView2InitializationCompleted({ param($source, $e) $ctrl = $source.Tag if (-not $e.IsSuccess) { $ctrl.showPlaceholder("Terminal unavailable - CoreWebView2 initialization failed: $($e.InitializationException.Message)") return } $ctrl.wireCoreWebView2() }) $this._webView.Tag = $this $this._webView.EnsureCoreWebView2Async($environment) | Out-Null } catch { $this.showPlaceholder("Terminal failed to initialize: $($_.Exception.Message)") } } hidden [void] wireCoreWebView2() { try { $core = $this._webView.CoreWebView2 $wwwPath = Join-Path $PSScriptRoot 'www' $kindType = Get-SRxWebView2Type 'Core.CoreWebView2HostResourceAccessKind' $allowKind = if ($kindType) { [Enum]::Parse($kindType, 'Allow') } else { 1 } # 1 == Allow, documented enum value, last-resort fallback $core.SetVirtualHostNameToFolderMapping('srx-terminal.local', $wwwPath, $allowKind) # $core (CoreWebView2) has NO .Tag property (GOTCHA #5) - capture # $control directly via .GetNewClosure() instead. $control = $this $onWebMessage = { param($source, $e) try { $control.onWebMessage($e.TryGetWebMessageAsString()) } catch { Write-Host "onWebMessage - $($_.Exception.Message)" } }.GetNewClosure() $core.add_WebMessageReceived($onWebMessage) $this._webViewReady = $true $core.Navigate('https://srx-terminal.local/terminal.html') $this.status("Terminal ready.") } catch { $this.showPlaceholder("Terminal failed to wire CoreWebView2: $($_.Exception.Message)") } } #-------------------------------------- # JS -> Host messages: {"t":"ready"} | {"t":"in","d":"..."} | {"t":"resize","cols":N,"rows":N} #-------------------------------------- hidden [void] onWebMessage([string]$json) { if ([string]::IsNullOrEmpty($json)) { return } $msg = $null try { $msg = $json | ConvertFrom-Json -ErrorAction Stop } catch { return } switch ([string]$msg.t) { 'ready' { $this.startConPty($this._lastCols, $this._lastRows) } 'in' { if ($this._conPty -and $msg.d) { $this._conPty.Write([string]$msg.d) } } 'resize' { $cols = [int]$msg.cols; $rows = [int]$msg.rows if ($cols -gt 0 -and $rows -gt 0) { $this._lastCols = $cols; $this._lastRows = $rows if ($this._conPty) { $this._conPty.Resize($cols, $rows) } } } default { } } } hidden [void] startConPty([int]$cols, [int]$rows) { if ($this._conPty) { return } try { $workDir = if ($this.SourcePath) { $this.SourcePath } else { $null } $this._conPty = Start-SRxConPtySession -CommandLine $this.StartupCommandLine -WorkingDirectory $workDir -Columns $cols -Rows $rows if (-not $this._conPty) { $this.status("Terminal: failed to start ConPTY session.") return } $this.status("Terminal session started (PID $($this._conPty.ProcessId)).") $this.startIoPump() #begin Terminal Host - Iteration 7 # Flush any RunCommand() calls that arrived before the ConPTY # session actually existed (e.g. the very first Activate(), while # WebView2/environment creation was still in flight). Runs on the # UI thread (this method is only ever reached via the _ioTimer/ # onWebMessage 'ready' path, both UI-thread), same thread # RunCommand() itself runs on - no extra synchronization needed. if ($this._pendingCommands -and $this._pendingCommands.Count -gt 0) { foreach ($pending in $this._pendingCommands) { $this._conPty.Write($pending + "`r") } $this._pendingCommands.Clear() } #end Terminal Host - Iteration 7 } catch { $this.status("Terminal: ConPTY start failed: $($_.Exception.Message)") } } #begin Terminal Host - Iteration 7 #-------------------------------------- # RunCommand() - lets the HOST (Explorer.onBeginCommand(), when "Enable # Terminal" is checked) type a command into this control's already- # running interactive session, exactly as if the user had typed it # themselves + pressed Enter. See module header comment for the queueing # rationale (ConPTY may not exist yet on the very first call). #-------------------------------------- [void] RunCommand([string]$text) { if ([string]::IsNullOrEmpty($text)) { return } if ($this._activeView -ne 'terminal') { $this.switchView('terminal') } # Iteration 10: make the output visible $this.Activate() # idempotent: boots WebView2/ConPTY on first call; just re-focuses otherwise if ($this._conPty) { $this._conPty.Write($text + "`r") } else { # ConPTY not started yet - queue for startConPty() to flush once # the session object is actually assigned (see startConPty() above). if (-not $this._pendingCommands) { $this._pendingCommands = New-Object 'System.Collections.Generic.List[string]' } $this._pendingCommands.Add($text) } } #end Terminal Host - Iteration 7 #begin RulesLibrary - Iteration 10 (Terminal-only Lib-update pipe support) #-------------------------------------- # GetProcessId() - exposes the currently-running ConPTY session's actual # process ID to the host (Explorer.ps1's pushLibToTerminal()), which uses # it to compute the name of the fixed, per-process pipe # ("pipe-Terminal-Lib-<PID>") that this Terminal-hosted shell.ps1 process # itself listens on (see shell.ps1's own Terminal-only pipe listener). # Returns $null/0 if no session is running yet (Terminal never opened, or # WebView2's own async startup chain is still in flight) - the host is # expected to treat that as "nothing to push to", not an error. #-------------------------------------- [int] GetProcessId() { if ($this._conPty) { return $this._conPty.ProcessId } return 0 } #end RulesLibrary - Iteration 10 #begin Terminal Host - Iteration 9 (bugfix - see module header ITERATION 9) #-------------------------------------- # RestartSession() - relaunches JUST the ConPTY session (kills the old # shell.ps1 process, starts a fresh one) using whatever # $this.StartupCommandLine is set to AT THE MOMENT THIS IS CALLED - # BUT ONLY IF A SESSION ALREADY EXISTED. See module header (ITERATION 9) # for the full race-condition rationale: if no session existed yet, this # means Activate()'s own first-time async startup chain (WebView2/ # CoreWebView2/xterm.js) is still in flight, and forcing an early # startConPty() here would race against it, causing the shell's initial # output to be silently discarded before the UI is ready to receive it # (the "black screen" bug). In that case we simply leave the freshly- # updated StartupCommandLine in place - the natural onWebMessage('ready') # path will create the session itself once xterm.js is genuinely ready. # # WebView2/xterm.js themselves are NEVER touched here either way - only # the ConPTY session + its I/O pump timer are ever torn down/recreated. # # The HOST is expected to update .StartupCommandLine to reflect the new # desired state (e.g. a freshly-computed -EnableDebug/-RulesLibPath # argument string) IMMEDIATELY BEFORE calling this method. #-------------------------------------- [void] RestartSession() { if (-not $this._started) { # Terminal was never activated at all - nothing running to # restart. The eventual first Activate() call will use whatever # StartupCommandLine is set at THAT time, which already # reflects the caller's latest update - nothing further to do. return } # Capture BEFORE tearing anything down: did a REAL session already # exist? This is the key fix - see ITERATION 9 above. $hadExistingSession = ($null -ne $this._conPty) # Stop the I/O pump timer FIRST (it references the OLD $this._conPty # instance via pumpConPtyIo() - stop polling before that instance is # torn down, to avoid a Tick firing mid-teardown). try { if ($this._ioTimer) { $this._ioTimer.Stop(); $this._ioTimer.Dispose() } } catch { } $this._ioTimer = $null # Gracefully stop the OLD ConPTY session (same graceful-then-kill # pattern/timeout as Shutdown() uses) - if there wasn't one yet # (e.g. WebView2 still initializing when this was called), this is # simply a no-op. try { if ($this._conPty) { $this._conPty.Stop(1500) } } catch { } $this._conPty = $null # Any text queued via RunCommand() for the OLD session no longer # applies to a brand-new process/session - drop it rather than # silently replaying stale input into the new shell. try { if ($this._pendingCommands) { $this._pendingCommands.Clear() } } catch { } if (-not $hadExistingSession) { # No real session existed yet - Activate()'s own first-time # async chain is still in flight (or WebView2 failed to start # at all). Do NOT force-start a ConPTY session here - see # ITERATION 9 header comment for why that would race against # the natural startup sequence and silently drop the shell's # initial output. StartupCommandLine has already been updated # by the caller, so whenever the natural 'ready' handshake # does complete, it will use the CURRENT, correct value. $this.status("Terminal settings updated - will apply once the session starts.") return } $this.status("Restarting terminal session...") # Best-effort: ask xterm.js to clear its on-screen buffer so the new # session starts with a clean slate instead of appearing to continue # below the old session's final output. This message type ('clear') # is a NEW addition - if the currently-deployed terminal.html predates # it, the message is simply ignored client-side (this codebase's # terminal.html JS uses an if/else-if chain with no fallback branch, # so an unrecognized "t" value is a harmless no-op) - functionally # safe either way, this is a pure visual nicety. try { if ($this._webView -and $this._webView.CoreWebView2) { $payload = @{ t = 'clear' } | ConvertTo-Json -Compress $this._webView.CoreWebView2.PostWebMessageAsJson($payload) } } catch { } # A real session existed before - genuinely restart it now, using # whatever StartupCommandLine is CURRENTLY set (the caller is # expected to have already updated it) and the last known terminal # size. startConPty() also (re)starts the I/O pump timer internally. $this.startConPty($this._lastCols, $this._lastRows) } #end Terminal Host - Iteration 9 hidden [void] pumpConPtyIo() { if (-not $this._conPty) { return } try { $chunk = $null $budget = 64 # drain at most N chunks per tick so the UI stays responsive under heavy output while ($budget -gt 0 -and $this._conPty.TryDequeueOutput([ref]$chunk)) { $budget-- if ($chunk -and $this._webView -and $this._webView.CoreWebView2) { # JSON int array so JS rebuilds an exact Uint8Array (no re-encoding of control bytes) $ints = New-Object 'System.Collections.Generic.List[int]' foreach ($b in $chunk) { [void]$ints.Add([int]$b) } $payload = @{ t = 'out'; d = $ints } | ConvertTo-Json -Compress -Depth 4 $this._webView.CoreWebView2.PostWebMessageAsJson($payload) } } if ($this._conPty.HasExited) { $code = $this._conPty.LastExitCode $this.status("Terminal session ended (exit code $code).") try { if ($this._webView -and $this._webView.CoreWebView2) { $payload = @{ t = 'exit'; code = $code } | ConvertTo-Json -Compress $this._webView.CoreWebView2.PostWebMessageAsJson($payload) } } catch { } $this._conPty = $null if ($this._ioTimer) { $this._ioTimer.Stop(); $this._ioTimer.Dispose(); $this._ioTimer = $null } } } catch { Write-Host "pumpConPtyIo - $($_.Exception.Message)" } } #-------------------------------------- # I/O pump: polls the ConPTY session's thread-safe output queue and exit # flag from a UI-thread Timer (GOTCHA #4). #-------------------------------------- hidden [void] startIoPump() { if ($this._ioTimer) { return } $this._ioTimer = New-Object System.Windows.Forms.Timer $this._ioTimer.Interval = 30 $this._ioTimer.Tag = $this $this._ioTimer.add_Tick({ param($source, $e) $ctrl = $source.Tag $ctrl.pumpConPtyIo() }) $this._ioTimer.Start() } hidden [void] showPlaceholder([string]$text) { try { if ($this._webView) { $this._webView.Visible = $false } $this._placeholder.Text = $text $this._placeholder.Visible = $true $this._placeholder.BringToFront() } catch { } } #begin Monaco Editor - Iteration 10 #========================================================================== # Monaco Editor - public API #========================================================================== # Opens (or re-selects) a file in an editor tab. Absolute path required. [object] OpenEditor([string]$path) { return $this.Editors.Open($path) } # Iteration 10.1 - $readOnly: $true = read-only, $false = read/write [object] OpenEditor([string]$path, [bool]$readOnly) { return $this.Editors.Open($path, $readOnly) } # Shows the terminal view (the "Terminal" label does the same). [void] ShowTerminal() { $this.switchView('terminal') $this.Activate() } [bool] HasUnsavedEditors() { return $this.Editors.HasUnsavedChanges() } # 'terminal' or the Id of the editor currently shown [string] ActiveView() { return $this._activeView } #========================================================================== # Owner contract - called by SRxMonacoEditorCollection (see SRxMonacoEditor.psm1) #========================================================================== hidden [void] editorOpened($editor, [string]$text) { $this.ensureEditorSurface() $this.addTab($editor) $this.postEditor(@{ t = 'open' id = [string]$editor.Id path = [string]$editor.Path name = [string]$editor.FileName lang = [string]$editor.Language text = $text eol = [string]$editor.NewLine ro = [bool]$editor.IsReadOnly # Iteration 10.1 }) } # Iteration 10.1 - read-only flag changed on an open editor hidden [void] editorReadOnlyChanged($editor) { $this.postEditor(@{ t = 'readonly'; id = [string]$editor.Id; ro = [bool]$editor.IsReadOnly }) $this.updateTab($editor) $this.updateSaveButton() } hidden [void] editorSelected($editor, [bool]$show) { if ($null -eq $editor) { # last editor closed while it was displayed -> back to the terminal if ($show) { $this.switchView('terminal') if ($this._started) { $this.Activate() } } $this.refreshTabs() return } if ($show) { $this.requestShow() $this.postEditor(@{ t = 'select'; id = [string]$editor.Id }) $this.switchView([string]$editor.Id) } else { $this.refreshTabs() } } # returns $true when the closed editor was on screen (next editor is shown) hidden [bool] editorClosed($editor) { $wasShown = ($this._activeView -eq [string]$editor.Id) if ($null -ne $this._promptEditor -and $this._promptEditor -eq $editor) { $this.hideOverlay() } $this.removeTab($editor) $this.postEditor(@{ t = 'close'; id = [string]$editor.Id }) if ($wasShown) { $this._activeView = '' } $this.updateSaveButton() return $wasShown } hidden [void] editorStateChanged($editor) { $this.updateTab($editor) $this.updateSaveButton() } hidden [void] promptUnsaved($editor) { $this.showOverlay($editor) } hidden [void] requestContent($editor, [string]$requestId) { $this.postEditor(@{ t = 'get'; id = [string]$editor.Id; req = $requestId }) } hidden [void] markSaved($editor, [int]$version) { $this.postEditor(@{ t = 'saved'; id = [string]$editor.Id; ver = $version }) } #========================================================================== # Editor surface (WebView2 + www/editor.html) #========================================================================== hidden [void] ensureEditorSurface() { if ($this._editorStarted) { return } $this._editorStarted = $true # requirement: VS Code check at editor activation (non-blocking) $this.checkVSCode() if ($this.DisableEditorSurface) { return } # tests: messages stay in _editorOutbox if (-not (Import-SRxWebView2Assemblies)) { $this.showEditorPlaceholder("Editor unavailable - WebView2 SDK assemblies are not deployed. See Terminal\Lib\WebView2.") $this.status("Editor unavailable (WebView2 SDK missing).") return } try { # string type name, not a bracket-literal (GOTCHA #1) $this._editorWebView = New-Object -TypeName 'Microsoft.Web.WebView2.WinForms.WebView2' $this._editorWebView.Dock = [System.Windows.Forms.DockStyle]::Fill [void]$this._editorPanel.Controls.Add($this._editorWebView) $this._editorWebView.BringToFront() $this.showEditorPlaceholder("Editor is starting...") $this.acquireEnvironment('editor') } catch { $this.showEditorPlaceholder("Editor failed to start: $($_.Exception.Message)") $this.status("Editor start failed: $($_.Exception.Message)") } } hidden [void] onEditorEnvironmentReady($environment) { if ($null -eq $this._editorWebView) { return } try { $this._editorWebView.add_CoreWebView2InitializationCompleted({ param($source, $e) $ctrl = $source.Tag if (-not $e.IsSuccess) { $ctrl.showEditorPlaceholder("Editor unavailable - CoreWebView2 initialization failed: $($e.InitializationException.Message)") return } $ctrl.wireEditorCoreWebView2() }) $this._editorWebView.Tag = $this $this._editorWebView.EnsureCoreWebView2Async($environment) | Out-Null } catch { $this.showEditorPlaceholder("Editor failed to initialize: $($_.Exception.Message)") } } hidden [void] wireEditorCoreWebView2() { try { $core = $this._editorWebView.CoreWebView2 $wwwPath = Join-Path $PSScriptRoot 'www' $kindType = Get-SRxWebView2Type 'Core.CoreWebView2HostResourceAccessKind' $allowKind = if ($kindType) { [Enum]::Parse($kindType, 'Allow') } else { 1 } $core.SetVirtualHostNameToFolderMapping('srx-terminal.local', $wwwPath, $allowKind) # CoreWebView2 has no .Tag (GOTCHA #5) - closure capture instead $control = $this $onMessage = { param($source, $e) try { $control.onEditorWebMessage($e.TryGetWebMessageAsString()) } catch { Write-Host "onEditorWebMessage - $($_.Exception.Message)" } }.GetNewClosure() $core.add_WebMessageReceived($onMessage) $theme = [System.Uri]::EscapeDataString($this.EditorTheme) $core.Navigate("https://srx-terminal.local/editor.html?theme=$theme") } catch { $this.showEditorPlaceholder("Editor failed to wire CoreWebView2: $($_.Exception.Message)") } } # JSON -> editor.html; queued until the page reports 'ready' hidden [void] postEditor([hashtable]$message) { $json = $null try { $json = ConvertTo-SRxJsonMessage -Message $message } catch { Write-Host "postEditor - serialize failed: $($_.Exception.Message)"; return } if ($this._editorJsReady -and $this._editorWebView -and $this._editorWebView.CoreWebView2) { try { $this._editorWebView.CoreWebView2.PostWebMessageAsJson($json) } catch { Write-Host "postEditor - $($_.Exception.Message)" } } else { $this._editorOutbox.Add($json) } } hidden [void] flushEditorOutbox() { if (-not ($this._editorWebView -and $this._editorWebView.CoreWebView2)) { return } $queued = $this._editorOutbox.ToArray() $this._editorOutbox.Clear() foreach ($json in $queued) { try { $this._editorWebView.CoreWebView2.PostWebMessageAsJson($json) } catch { Write-Host "flushEditorOutbox - $($_.Exception.Message)" } } } hidden [object] msgValue($message, [string]$key) { if ($null -ne $message -and $message.ContainsKey($key)) { return $message[$key] } return $null } # editor.html -> host. Runs on the UI thread (WebMessageReceived). hidden [void] onEditorWebMessage([string]$json) { $msg = ConvertFrom-SRxJsonMessage -Json $json if ($null -eq $msg) { return } $t = [string]$this.msgValue($msg, 't') $id = [string]$this.msgValue($msg, 'id') switch ($t) { 'ready' { $this._editorJsReady = $true $this._editorPlaceholder.Visible = $false if ($this._editorWebView) { $this._editorWebView.Visible = $true } $this.flushEditorOutbox() $this.status("Editor ready (Monaco: $([string]$this.msgValue($msg, 'source'))).") } 'dirty' { $this.Editors.SetDirty($id, [bool]$this.msgValue($msg, 'dirty')) } 'content' { $ver = 0 try { $ver = [int]$this.msgValue($msg, 'ver') } catch { $ver = 0 } $this.Editors.CompleteContentRequest([string]$this.msgValue($msg, 'req'), [string]$this.msgValue($msg, 'd'), $ver) } 'contentError' { $this.Editors.FailContentRequest([string]$this.msgValue($msg, 'req'), [string]$this.msgValue($msg, 'msg')) } 'save' { $ed = $this.Editors.GetById($id) if ($null -ne $ed -and -not $ed.IsReadOnly) { [void]$ed.Save() } # Iteration 10.1 guard } 'error' { $text = [string]$this.msgValue($msg, 'msg') $this.showEditorPlaceholder("Editor unavailable - $text") $this.status("Editor error: $text") } default { } } } hidden [void] showEditorPlaceholder([string]$text) { try { if ($this._editorWebView) { $this._editorWebView.Visible = $false } $this._editorPlaceholder.Text = $text $this._editorPlaceholder.Visible = $true $this._editorPlaceholder.BringToFront() if ($this._overlay -and $this._overlay.Visible) { $this._overlay.BringToFront() } } catch { } } #========================================================================== # View switching #========================================================================== hidden [void] switchView([string]$view) { if ([string]::IsNullOrEmpty($view)) { $view = 'terminal' } $this._activeView = $view try { if ($view -eq 'terminal') { $this._editorPanel.Visible = $false $this._terminalPanel.Visible = $true $this._terminalPanel.BringToFront() } else { $this._terminalPanel.Visible = $false $this._editorPanel.Visible = $true $this._editorPanel.BringToFront() if ($this._editorWebView -and $this._editorWebView.Visible) { try { $this._editorWebView.Focus() | Out-Null } catch { } } } } catch { Write-Host "switchView - $($_.Exception.Message)" } $this.refreshTabs() $this.updateSaveButton() } hidden [void] requestShow() { try { if ($this.OnRequestShow) { $this.OnRequestShow.Invoke() } } catch { } } hidden [object] displayedEditor() { if ($this._activeView -eq 'terminal' -or [string]::IsNullOrEmpty($this._activeView)) { return $null } return $this.Editors.GetById($this._activeView) } #========================================================================== # Tabs: [x file.ps1] - ToolStripControlHost(Panel{PictureBox cross, Label title}) #========================================================================== hidden [void] addTab($editor) { try { $id = [string]$editor.Id if ($this._tabs.ContainsKey($id)) { return } $tagObj = [pscustomobject]@{ Control = $this; Editor = $editor } # Iteration 10.1 - same font + same vertical slot as the "Terminal" label $font = $this.tabFont() $labelMargin = $this._titleLabel.Margin $tabHeight = $this.tabHeight() $panel = [System.Windows.Forms.Panel]::new() $panel.Height = $tabHeight $panel.Font = $font $panel.Margin = [System.Windows.Forms.Padding]::new(0) $panel.BackColor = [System.Drawing.SystemColors]::ControlLight $panel.Cursor = [System.Windows.Forms.Cursors]::Hand $panel.Tag = $tagObj $close = [System.Windows.Forms.PictureBox]::new() $close.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom $close.Size = [System.Drawing.Size]::new(14, 14) $close.Location = [System.Drawing.Point]::new(6, [int][Math]::Floor(($tabHeight - 14) / 2)) $close.BackColor = [System.Drawing.Color]::Transparent $close.Cursor = [System.Windows.Forms.Cursors]::Hand $close.Tag = $tagObj $label = [System.Windows.Forms.Label]::new() $label.AutoSize = $false $label.Location = [System.Drawing.Point]::new(24, 0) $label.Height = $tabHeight $label.Padding = [System.Windows.Forms.Padding]::new(0) $label.Margin = [System.Windows.Forms.Padding]::new(0) $label.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft $label.UseCompatibleTextRendering = $false # GDI TextRenderer, as the ToolStrip renderer $label.UseMnemonic = $false # '&' in file names is literal $label.Font = $font $label.BackColor = [System.Drawing.Color]::Transparent $label.Cursor = [System.Windows.Forms.Cursors]::Hand $label.Text = $editor.DisplayTitle() $label.Tag = $tagObj [void]$panel.Controls.Add($close) [void]$panel.Controls.Add($label) $close.add_Click({ param($s, $e) $s.Tag.Control.onTabCloseClick($s.Tag.Editor) }) $close.add_MouseEnter({ param($s, $e) $s.BackColor = [System.Drawing.Color]::FromArgb(205, 220, 235) }) $close.add_MouseLeave({ param($s, $e) $s.BackColor = [System.Drawing.Color]::Transparent }) $label.add_Click({ param($s, $e) $s.Tag.Control.onTabClick($s.Tag.Editor) }) $panel.add_Click({ param($s, $e) $s.Tag.Control.onTabClick($s.Tag.Editor) }) # middle-click closes (VS Code convention) $label.add_MouseUp({ param($s, $e) if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Middle) { $s.Tag.Control.onTabCloseClick($s.Tag.Editor) } }) $panel.add_MouseUp({ param($s, $e) if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Middle) { $s.Tag.Control.onTabCloseClick($s.Tag.Editor) } }) $tabHost = [System.Windows.Forms.ToolStripControlHost]::new($panel) $tabHost.AutoSize = $false $tabHost.Margin = [System.Windows.Forms.Padding]::new(2, $labelMargin.Top, 0, $labelMargin.Bottom) $tabHost.Name = "tab_$id" if ($null -eq $this._tabToolTip) { $this._tabToolTip = [System.Windows.Forms.ToolTip]::new() } $this._tabToolTip.SetToolTip($label, $this.tabToolTipText($editor)) $this._tabToolTip.SetToolTip($panel, $this.tabToolTipText($editor)) $this._tabToolTip.SetToolTip($close, 'Close') $tab = @{ Host = $tabHost; Panel = $panel; Close = $close; Label = $label; Editor = $editor } $this._tabs[$id] = $tab $this.applyTabIcon($tab) $this.sizeTab($tab) # insert after the last tab, i.e. right before the (right-aligned) close button $idx = $this._headerPanel.Items.IndexOf($this._closeButton) if ($idx -lt 0) { [void]$this._headerPanel.Items.Add($tabHost) } else { $this._headerPanel.Items.Insert($idx, $tabHost) } } catch { Write-Host "addTab - $($_.Exception.Message)" } } hidden [void] sizeTab($tab) { $flags = [System.Windows.Forms.TextFormatFlags]::NoPrefix -bor [System.Windows.Forms.TextFormatFlags]::SingleLine $w = [System.Windows.Forms.TextRenderer]::MeasureText([string]$tab.Label.Text, $tab.Label.Font, [System.Drawing.Size]::new([int]::MaxValue, [int]::MaxValue), $flags).Width $h = $this.tabHeight() $tab.Label.Size = [System.Drawing.Size]::new($w + 2, $h) $tab.Panel.Size = [System.Drawing.Size]::new(24 + $w + 2 + 6, $h) $tab.Close.Top = [int][Math]::Floor(($h - $tab.Close.Height) / 2) $tab.Host.Size = $tab.Panel.Size } #begin Monaco Editor - Iteration 10.1 (tab typography) # The strip's own font - exactly what the "Terminal" ToolStripLabel renders with. hidden [object] tabFont() { $f = $this._titleLabel.Font if ($null -eq $f) { $f = $this._headerPanel.Font } return $f } # Height of the "Terminal" label's layout slot: strip display height # minus the label's vertical Margin (ToolStrip lays both items out in # the same row, vertically centered -> identical text rectangles). hidden [int] tabHeight() { $m = $this._titleLabel.Margin $h = $this._headerPanel.DisplayRectangle.Height - $m.Top - $m.Bottom if ($h -lt 16) { $h = $this._headerPanel.Height - $m.Top - $m.Bottom } if ($h -lt 16) { $h = 26 } return $h } hidden [string] tabToolTipText($editor) { if ($editor.IsReadOnly) { return ([string]$editor.Path + ' (read-only)') } return [string]$editor.Path } # diagnostics/tests: font + text rectangles of the "Terminal" label and a tab title [object] TabLayoutInfo([string]$editorId) { if (-not $this._tabs.ContainsKey($editorId)) { return $null } $tab = $this._tabs[$editorId] $hostB = $tab.Host.Bounds return [pscustomobject]@{ TitleFont = $this._titleLabel.Font TabFont = $tab.Label.Font SameFont = $this._titleLabel.Font.Equals($tab.Label.Font) TitleBounds = $this._titleLabel.Bounds TabBounds = [System.Drawing.Rectangle]::new($hostB.X + $tab.Label.Left, $hostB.Y + $tab.Label.Top, $tab.Label.Width, $tab.Label.Height) TitleTextAlign = $this._titleLabel.TextAlign TabTextAlign = $tab.Label.TextAlign } } #end Monaco Editor - Iteration 10.1 hidden [void] applyTabIcon($tab) { try { if ($this.Icons.ContainsKey('Close') -and $this.Icons['Close']) { $tab.Close.Image = $this.Icons['Close'] } elseif ($null -eq $tab.Close.Image) { $tab.Close.Image = $this.crossBitmap() } } catch { } } # fallback pictogram when the host supplied no 'Close' icon hidden [object] crossBitmap() { if ($null -eq $this._crossBitmap) { $bmp = [System.Drawing.Bitmap]::new(14, 14) $g = [System.Drawing.Graphics]::FromImage($bmp) $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias $pen = [System.Drawing.Pen]::new([System.Drawing.Color]::FromArgb(60, 60, 60), 1.6) $g.DrawLine($pen, 3, 3, 10, 10) $g.DrawLine($pen, 10, 3, 3, 10) $pen.Dispose(); $g.Dispose() $this._crossBitmap = $bmp } return $this._crossBitmap } hidden [void] updateTab($editor) { try { $id = [string]$editor.Id if (-not $this._tabs.ContainsKey($id)) { return } $tab = $this._tabs[$id] $tab.Label.Text = $editor.DisplayTitle() if ($this._tabToolTip) { $this._tabToolTip.SetToolTip($tab.Label, $this.tabToolTipText($editor)) $this._tabToolTip.SetToolTip($tab.Panel, $this.tabToolTipText($editor)) } $this.sizeTab($tab) } catch { Write-Host "updateTab - $($_.Exception.Message)" } } hidden [void] removeTab($editor) { try { $id = [string]$editor.Id if (-not $this._tabs.ContainsKey($id)) { return } $tab = $this._tabs[$id] $this._tabs.Remove($id) [void]$this._headerPanel.Items.Remove($tab.Host) try { $tab.Host.Dispose() } catch { } } catch { Write-Host "removeTab - $($_.Exception.Message)" } } hidden [void] refreshTabs() { try { $activeFore = [System.Drawing.Color]::Black $inactiveFore = [System.Drawing.Color]::DimGray $this._titleLabel.ForeColor = $(if ($this._activeView -eq 'terminal') { $activeFore } else { $inactiveFore }) foreach ($tab in @($this._tabs.Values)) { $isActive = ($this._activeView -eq [string]$tab.Editor.Id) $tab.Panel.BackColor = $(if ($isActive) { [System.Drawing.Color]::White } else { [System.Drawing.SystemColors]::ControlLight }) $tab.Label.ForeColor = $(if ($isActive) { $activeFore } else { $inactiveFore }) } } catch { Write-Host "refreshTabs - $($_.Exception.Message)" } } # test/diagnostic helpers [string[]] TabTitles() { $titles = New-Object 'System.Collections.Generic.List[string]' foreach ($item in $this._headerPanel.Items) { if ($item -is [System.Windows.Forms.ToolStripControlHost] -and $item.Name -like 'tab_*') { $titles.Add([string]$item.Control.Controls[1].Text) } } return $titles.ToArray() } hidden [void] onTabClick($editor) { if ($null -eq $editor -or $editor.IsClosed) { return } $editor.Select() } hidden [void] onTabCloseClick($editor) { if ($null -eq $editor -or $editor.IsClosed) { return } [void]$editor.Close() } #========================================================================== # Save button (left of the strip) - mirrors SRxPropertyBoxControl #========================================================================== hidden [void] updateSaveButton() { try { $ed = $this.displayedEditor() $show = $false $saving = $false if ($null -ne $ed) { # Iteration 10.1 guard: never for a read-only editor $show = ([bool]$ed.IsDirty -and -not [bool]$ed.IsReadOnly) $saving = [bool]$ed.IsSaving } $this._btnSave.Visible = $show $this._btnSave.Enabled = ($show -and -not $saving) if ($show) { if ($this.Icons.ContainsKey('Save') -and $this.Icons['Save']) { $this._btnSave.Image = $this.Icons['Save'] } $this._btnSave.ToolTipText = "Save $($ed.FileName) (Ctrl+S)" } } catch { Write-Host "updateSaveButton - $($_.Exception.Message)" } } # .Available = the item's own visibility flag (.Visible also requires the # parent ToolStrip to be on screen) [bool] IsSaveButtonVisible() { return [bool]$this._btnSave.Available } hidden [void] onSaveButtonClick() { try { $ed = $this.displayedEditor() if ($null -ne $ed -and -not $ed.IsReadOnly) { [void]$ed.Save() } # Iteration 10.1 guard } catch { Write-Host "onSaveButtonClick - $($_.Exception.Message)" } } hidden [void] onSaveButtonMouseEnter() { try { if (-not $this._btnSave.Enabled) { return } $this._btnSave.BackColor = [System.Drawing.Color]::FromArgb(225, 235, 245) $this._headerPanel.Cursor = [System.Windows.Forms.Cursors]::Hand } catch { } } hidden [void] onSaveButtonMouseLeave() { try { $this._btnSave.BackColor = [System.Drawing.Color]::Transparent $this._headerPanel.Cursor = [System.Windows.Forms.Cursors]::Default } catch { } } #========================================================================== # Unsaved-changes overlay - same look/flow as SRxPropertyBoxControl #========================================================================== hidden [void] buildOverlay() { $this._overlay = [System.Windows.Forms.Panel]::new() $this._overlay.BackColor = [System.Drawing.Color]::FromArgb(250, 250, 250) $this._overlay.BorderStyle = [System.Windows.Forms.BorderStyle]::FixedSingle $this._overlay.Visible = $false $this._overlay.Dock = [System.Windows.Forms.DockStyle]::Fill $this._overlayMsg = [System.Windows.Forms.Label]::new() $this._overlayMsg.AutoSize = $false $this._overlayMsg.Font = [System.Drawing.Font]::new('Segoe UI', 9) $this._overlayMsg.ForeColor = [System.Drawing.Color]::FromArgb(30, 30, 30) $this._overlayMsg.Location = [System.Drawing.Point]::new(16, 16) $this._overlayMsg.Size = [System.Drawing.Size]::new(360, 48) $this._overlayMsg.Text = 'You have unsaved changes.' [void]$this._overlay.Controls.Add($this._overlayMsg) $this._overlaySave = [System.Windows.Forms.Button]::new() $this._overlaySave.Text = 'Save changes' $this._overlaySave.Font = [System.Drawing.Font]::new('Segoe UI', 9) $this._overlaySave.Size = [System.Drawing.Size]::new(160, 30) $this._overlaySave.Location = [System.Drawing.Point]::new(16, 76) $this._overlaySave.FlatStyle = [System.Windows.Forms.FlatStyle]::System $this._overlaySave.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft $this._overlaySave.ImageAlign = [System.Drawing.ContentAlignment]::MiddleLeft $this._overlaySave.TextImageRelation = [System.Windows.Forms.TextImageRelation]::ImageBeforeText $this._overlaySave.Padding = [System.Windows.Forms.Padding]::new(6, 0, 0, 0) $this._overlaySave.Tag = $this $this._overlaySave.add_Click({ param($s, $e) $s.Tag.onOverlaySave() }) [void]$this._overlay.Controls.Add($this._overlaySave) $this._overlayDiscard = [System.Windows.Forms.Button]::new() $this._overlayDiscard.Text = 'Discard changes' $this._overlayDiscard.Font = [System.Drawing.Font]::new('Segoe UI', 9) $this._overlayDiscard.Size = [System.Drawing.Size]::new(160, 30) $this._overlayDiscard.Location = [System.Drawing.Point]::new(16, 112) $this._overlayDiscard.FlatStyle = [System.Windows.Forms.FlatStyle]::System $this._overlayDiscard.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft $this._overlayDiscard.Tag = $this $this._overlayDiscard.add_Click({ param($s, $e) $s.Tag.onOverlayDiscard() }) [void]$this._overlay.Controls.Add($this._overlayDiscard) $this._overlayCancel = [System.Windows.Forms.Button]::new() $this._overlayCancel.Text = 'Cancel' $this._overlayCancel.Font = [System.Drawing.Font]::new('Segoe UI', 9) $this._overlayCancel.Size = [System.Drawing.Size]::new(160, 30) $this._overlayCancel.Location = [System.Drawing.Point]::new(16, 148) $this._overlayCancel.FlatStyle = [System.Windows.Forms.FlatStyle]::System $this._overlayCancel.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft $this._overlayCancel.Tag = $this $this._overlayCancel.add_Click({ param($s, $e) $s.Tag.onOverlayCancel() }) [void]$this._overlay.Controls.Add($this._overlayCancel) [void]$this._editorPanel.Controls.Add($this._overlay) } hidden [void] showOverlay($editor) { try { $this._promptEditor = $editor if ($this._activeView -ne [string]$editor.Id) { $this.switchView([string]$editor.Id) } $this._overlayMsg.Text = "You have unsaved changes in '$($editor.FileName)'.`r`nWhat would you like to do?" if ($this.Icons.ContainsKey('Save') -and $this.Icons['Save']) { $this._overlaySave.Image = $this.Icons['Save'] } $this._overlay.Dock = [System.Windows.Forms.DockStyle]::Fill $this._overlay.Visible = $true $this._overlay.BringToFront() } catch { Write-Host "showOverlay - $($_.Exception.Message)" } } hidden [void] hideOverlay() { try { $this._overlay.Visible = $false } catch { } $this._promptEditor = $null } [bool] IsPromptVisible() { return ($null -ne $this._promptEditor) } hidden [void] onOverlaySave() { $ed = $this._promptEditor $this.hideOverlay() if ($null -ne $ed) { $this.Editors.ResolvePrompt($ed, 'save') } } hidden [void] onOverlayDiscard() { $ed = $this._promptEditor $this.hideOverlay() if ($null -ne $ed) { $this.Editors.ResolvePrompt($ed, 'discard') } } hidden [void] onOverlayCancel() { $ed = $this._promptEditor $this.hideOverlay() if ($null -ne $ed) { $this.Editors.ResolvePrompt($ed, 'cancel') } } #========================================================================== # VS Code provisioning (once, at first editor activation) #========================================================================== hidden [void] checkVSCode() { if ($this._vscodeChecked -or -not $this.EnsureVSCode) { return } $this._vscodeChecked = $true try { $det = Test-SRxVSCodeInstalled if ($det.Installed) { return } # installed -> DO NOTHING $this._vscodeInstall = Start-SRxVSCodeUserInstall -Detection $det -BuildEdition $this.VSCodeBuildEdition if ($null -eq $this._vscodeInstall) { return } $this.status("VS Code not found - installing it for the current user in the background (log: $($this._vscodeInstall.LogPath)).") $this._vscodeTimer = New-Object System.Windows.Forms.Timer $this._vscodeTimer.Interval = 2000 $this._vscodeTimer.Tag = $this $this._vscodeTimer.add_Tick({ param($s, $e) $s.Tag.pollVSCodeInstall() }) $this._vscodeTimer.Start() } catch { Write-Host "checkVSCode - $($_.Exception.Message)" } } hidden [void] pollVSCodeInstall() { try { $p = $null if ($this._vscodeInstall) { $p = $this._vscodeInstall.Process } if ($null -ne $p -and -not $p.HasExited) { return } if ($this._vscodeTimer) { $this._vscodeTimer.Stop(); $this._vscodeTimer.Dispose(); $this._vscodeTimer = $null } $code = $null try { if ($p) { $code = $p.ExitCode } } catch { } $det = Test-SRxVSCodeInstalled if ($det.Installed) { $this.status("VS Code installed for the current user ($($det.Path)).") } else { $this.status("VS Code installation did not complete (exit code $code) - see $($this._vscodeInstall.LogPath).") } } catch { Write-Host "pollVSCodeInstall - $($_.Exception.Message)" } } #end Monaco Editor - Iteration 10 #-------------------------------------- # Shutdown() - call from the host's FormClosed cleanup (mirrors # $this._propBox.Shutdown()) so no orphan powershell.exe or Timer survives. #-------------------------------------- [void] Shutdown() { try { if ($this._startupTimer) { $this._startupTimer.Stop(); $this._startupTimer.Dispose() } } catch { } $this._startupTimer = $null try { if ($this._ioTimer) { $this._ioTimer.Stop(); $this._ioTimer.Dispose() } } catch { } $this._ioTimer = $null try { if ($this._conPty) { $this._conPty.Stop(1500) } } catch { } $this._conPty = $null try { if ($this._webView) { $this._webView.Dispose() } } catch { } $this._webView = $null #begin Monaco Editor - Iteration 10 # Unsaved editor changes are NOT prompted here - the host should ask # HasUnsavedEditors() in FormClosing if it wants to block the close. # A running VS Code install is left to finish on its own. try { if ($this._vscodeTimer) { $this._vscodeTimer.Stop(); $this._vscodeTimer.Dispose() } } catch { } $this._vscodeTimer = $null try { if ($this.Editors) { $this.Editors.Clear() } } catch { } try { if ($this._editorWebView) { $this._editorWebView.Dispose() } } catch { } $this._editorWebView = $null $this._editorJsReady = $false try { if ($this._tabToolTip) { $this._tabToolTip.Dispose() } } catch { } $this._tabToolTip = $null #end Monaco Editor - Iteration 10 } } function New-SRxTerminalBoxControl { [OutputType([object])] param() return [SRxTerminalBoxControl]::new() } Export-ModuleMember -Function New-SRxTerminalBoxControl, Import-SRxWebView2Assemblies, Get-SRxWebView2Type, Get-SRxLoadedAssembly |