Terminal/SRxMonacoEditor.psm1

<#
    SRxMonacoEditor.psm1 (Terminal subsystem - Iteration 10.1: Monaco Editor + read-only mode)
    ------------------------------------------------------------------------
    UI-FREE logic layer for the Monaco editor tabs hosted by
    SRxTerminalBoxControl. Contains NO WinForms and NO WebView2 type
    literals, so it imports cleanly anywhere and is fully testable headless.
 
      - Read-SRxEditorFile / Write-SRxEditorFile
            encoding-preserving (UTF-8 +/- BOM, UTF-16 LE/BE, UTF-32, ANSI)
            and line-ending-preserving (CRLF / LF) file I/O; atomic write
            (temp file + File.Replace) so a failed save never truncates the
            original file.
      - Get-SRxMonacoLanguage file extension -> Monaco language id
      - ConvertTo-/ConvertFrom-SRxJsonMessage
            JavaScriptSerializer with MaxJsonLength = Int32.MaxValue.
            Windows PowerShell 5.1 ConvertFrom-Json is unreliable for large
            payloads (whole file contents travel through these messages).
      - class SRxMonacoEditor one open file (one Monaco model)
      - class SRxMonacoEditorCollection Open / Get / Select / Save / Close
 
    OWNER CONTRACT (dynamic dispatch - SRxTerminalBoxControl implements it,
    tests implement a mock). Every call is wrapped in try/catch here.
        editorOpened($editor, [string]$text) create tab + Monaco model
        editorSelected($editor, [bool]$show) $editor may be $null (= no editors left)
        editorClosed($editor) -> [bool] remove tab + model; return $true when
                                               the next editor should be SHOWN
        editorStateChanged($editor) dirty/saving changed (tab text, save button)
        promptUnsaved($editor) show Save / Discard / Cancel prompt
        requestContent($editor, [string]$req) ask Monaco for the text; the answer comes
                                               back via CompleteContentRequest()
        markSaved($editor, [int]$version) tell Monaco which version is now clean
        editorReadOnlyChanged($editor) (Iteration 10.1) read-only flag changed on an
                                               OPEN editor - lock/unlock Monaco, hide Save
        status([string]$text)
 
    READ-ONLY MODE (Iteration 10.1)
        Open($path, $true) -> read-only editor (Monaco readOnly + domReadOnly)
        Open($path, $false) -> read/write editor
        Open($path) -> new file: read/write; already open: mode unchanged
        $editor.SetReadOnly($bool) switches an open editor at any time. Switching
        to read-only is REFUSED (returns $false, LastError set) while the editor
        has unsaved changes or a save in flight - edits are never silently lost.
        Guards for a read-only editor: Save() returns $false without requesting
        content, SetDirty($true) is ignored, a late content reply is never
        written, Close() never prompts - so the Save button can never appear.
 
    SAVE IS ASYNCHRONOUS: the text lives in Monaco (JavaScript). Save() posts a
    content request; the reply (UI thread, WebMessageReceived) calls
    CompleteContentRequest(), which writes the file. CommitText() is the
    synchronous core and can be called directly.
    ------------------------------------------------------------------------
#>


#region ---- JSON message helpers ---------------------------------------------
function Get-SRxJsonSerializer {
    [CmdletBinding()]
    param()
    Add-Type -AssemblyName System.Web.Extensions -ErrorAction Stop
    $ser = New-Object -TypeName 'System.Web.Script.Serialization.JavaScriptSerializer'
    $ser.MaxJsonLength = [int]::MaxValue
    $ser.RecursionLimit = 64
    return $ser
}

function ConvertTo-SRxJsonMessage {
    <#
        .SYNOPSIS
        Serializes a hashtable to compact JSON (no size limit).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][System.Collections.IDictionary]$Message)
    $ser = Get-SRxJsonSerializer
    return $ser.Serialize($Message)
}

function ConvertFrom-SRxJsonMessage {
    <#
        .SYNOPSIS
        Parses a JSON object string into a Dictionary[string,object]
        (index with $msg['key']). Returns $null for empty/invalid input.
    #>

    [CmdletBinding()]
    param([string]$Json)
    if ([string]::IsNullOrEmpty($Json)) { return $null }
    try {
        $ser = Get-SRxJsonSerializer
        $obj = $ser.DeserializeObject($Json)
        if ($obj -is [System.Collections.IDictionary]) { return ,$obj }
        return $null
    }
    catch { return $null }
}
#endregion

#region ---- language map -----------------------------------------------------
function Get-SRxMonacoLanguage {
    <#
        .SYNOPSIS
        Maps a file path to a Monaco language id ('plaintext' if unknown).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][string]$Path)
    $ext = ([System.IO.Path]::GetExtension($Path)).ToLowerInvariant()
    switch ($ext) {
        { $_ -in '.ps1', '.psm1', '.psd1', '.ps1xml' } { return 'powershell' }
        { $_ -in '.xml', '.xsd', '.xsl', '.xslt', '.config', '.csproj', '.resx', '.pnp' } { return 'xml' }
        { $_ -in '.json', '.jsonc' }        { return 'json' }
        { $_ -in '.js', '.mjs', '.cjs' }    { return 'javascript' }
        '.ts'                               { return 'typescript' }
        { $_ -in '.html', '.htm' }          { return 'html' }
        '.css'                              { return 'css' }
        { $_ -in '.md', '.markdown' }       { return 'markdown' }
        '.cs'                               { return 'csharp' }
        { $_ -in '.yml', '.yaml' }          { return 'yaml' }
        '.sql'                              { return 'sql' }
        { $_ -in '.bat', '.cmd' }           { return 'bat' }
        { $_ -in '.ini', '.inf', '.properties' } { return 'ini' }
        default                             { return 'plaintext' }
    }
    return 'plaintext'
}
#endregion

#region ---- encoding-preserving file I/O --------------------------------------
function Get-SRxEditorEncoding {
    <#
        .SYNOPSIS
        Returns the System.Text.Encoding for an EncodingName produced by
        Read-SRxEditorFile ('utf-8','utf-16','utf-16BE','utf-32','ansi').
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$EncodingName,
        [bool]$HasBom = $false
    )
    switch ($EncodingName) {
        'utf-8'    { return (New-Object System.Text.UTF8Encoding -ArgumentList $HasBom) }
        'utf-16'   { return (New-Object System.Text.UnicodeEncoding -ArgumentList $false, $HasBom) }
        'utf-16BE' { return (New-Object System.Text.UnicodeEncoding -ArgumentList $true, $HasBom) }
        'utf-32'   { return (New-Object System.Text.UTF32Encoding -ArgumentList $false, $HasBom) }
        'ansi'     { return [System.Text.Encoding]::Default }
        default    { return (New-Object System.Text.UTF8Encoding -ArgumentList $HasBom) }
    }
    return (New-Object System.Text.UTF8Encoding -ArgumentList $HasBom)
}

function Read-SRxEditorFile {
    <#
        .SYNOPSIS
        Reads a text file, detecting encoding (BOM / strict UTF-8 / ANSI) and
        the dominant line ending. Throws on I/O errors.
        .OUTPUTS
        [pscustomobject] Path, Text, EncodingName, HasBom, NewLine
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)

    $bytes = [System.IO.File]::ReadAllBytes($Path)
    $n = $bytes.Length
    $encName = 'utf-8'; $hasBom = $false; $skip = 0

    if ($n -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
        $encName = 'utf-8'; $hasBom = $true; $skip = 3
    }
    elseif ($n -ge 4 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE -and $bytes[2] -eq 0 -and $bytes[3] -eq 0) {
        $encName = 'utf-32'; $hasBom = $true; $skip = 4
    }
    elseif ($n -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
        $encName = 'utf-16'; $hasBom = $true; $skip = 2
    }
    elseif ($n -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
        $encName = 'utf-16BE'; $hasBom = $true; $skip = 2
    }

    $text = ''
    if ($hasBom) {
        $enc = Get-SRxEditorEncoding -EncodingName $encName -HasBom $true
        $text = $enc.GetString($bytes, $skip, $n - $skip)
    }
    else {
        # No BOM: strict UTF-8 first (throws on invalid sequences), else ANSI.
        $strict = New-Object System.Text.UTF8Encoding -ArgumentList $false, $true
        try {
            $text = $strict.GetString($bytes)
            $encName = 'utf-8'
        }
        catch {
            $encName = 'ansi'
            $text = [System.Text.Encoding]::Default.GetString($bytes)
        }
    }

    $newLine = "`r`n"
    if ($text.IndexOf("`r`n") -lt 0 -and $text.IndexOf("`n") -ge 0) { $newLine = "`n" }

    return [pscustomobject]@{
        Path         = $Path
        Text         = $text
        EncodingName = $encName
        HasBom       = $hasBom
        NewLine      = $newLine
    }
}

function Write-SRxEditorFile {
    <#
        .SYNOPSIS
        Writes text back preserving encoding/BOM and normalizing line endings
        to -NewLine. Atomic: writes a temp file in the same folder, then
        File.Replace (falls back to copy-over). Never throws.
        .OUTPUTS
        [pscustomobject] Success, Error, Bytes
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        [AllowEmptyString()][string]$Text = '',
        [string]$EncodingName = 'utf-8',
        [bool]$HasBom = $false,
        [string]$NewLine = "`r`n"
    )
    $tmp = $null
    try {
        if ($null -eq $Text) { $Text = '' }
        if ([System.IO.File]::Exists($Path)) {
            $attr = [System.IO.File]::GetAttributes($Path)
            if (($attr -band [System.IO.FileAttributes]::ReadOnly) -ne 0) {
                return [pscustomobject]@{ Success = $false; Error = "File is read-only: $Path"; Bytes = 0 }
            }
        }

        # normalize line endings to the file's original convention
        $normalized = $Text.Replace("`r`n", "`n").Replace("`r", "`n")
        if ($NewLine -eq "`r`n") { $normalized = $normalized.Replace("`n", "`r`n") }

        $enc = Get-SRxEditorEncoding -EncodingName $EncodingName -HasBom $HasBom
        $dir = [System.IO.Path]::GetDirectoryName($Path)
        $tmp = [System.IO.Path]::Combine($dir, ('.~' + [System.IO.Path]::GetFileName($Path) + '.' + [guid]::NewGuid().ToString('N') + '.tmp'))

        # WriteAllText emits the preamble (BOM) only if the encoding has one
        [System.IO.File]::WriteAllText($tmp, $normalized, $enc)
        $len = (New-Object System.IO.FileInfo -ArgumentList $tmp).Length

        if ([System.IO.File]::Exists($Path)) {
            try {
                [System.IO.File]::Replace($tmp, $Path, [NullString]::Value)
            }
            catch {
                # e.g. some network shares do not support ReplaceFile
                [System.IO.File]::Copy($tmp, $Path, $true)
            }
        }
        else {
            [System.IO.File]::Move($tmp, $Path)
        }
        return [pscustomobject]@{ Success = $true; Error = ''; Bytes = [long]$len }
    }
    catch {
        return [pscustomobject]@{ Success = $false; Error = $_.Exception.Message; Bytes = 0 }
    }
    finally {
        try { if ($tmp -and [System.IO.File]::Exists($tmp)) { [System.IO.File]::Delete($tmp) } } catch { }
    }
}

function Test-SRxAbsolutePath {
    <#
        .SYNOPSIS
        $true for 'C:\...' / 'C:/...' or UNC '\\server\share\...'. Rejects
        relative and drive-relative ('\foo', 'C:foo') paths.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([string]$Path)
    if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
    return ($Path -match '^(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)')
}
#endregion

#region ---- SRxMonacoEditor (one open file) -----------------------------------
class SRxMonacoEditor {
    [string] $Id = ''
    [string] $Path = ''
    [string] $FileName = ''
    [string] $Language = 'plaintext'
    [string] $EncodingName = 'utf-8'
    [bool]   $HasBom = $false
    [string] $NewLine = "`r`n"
    [bool]   $IsDirty = $false
    [bool]   $IsSaving = $false
    [bool]   $IsClosed = $false
    [bool]   $IsReadOnly = $false      # Iteration 10.1 - change via SetReadOnly()
    [string] $LastError = ''
    [object] $LastSaved = $null
    hidden [object] $_collection = $null

    SRxMonacoEditor([object]$collection, [string]$fullPath, [object]$doc) {
        $this._collection = $collection
        $this.Id = [guid]::NewGuid().ToString('N')
        $this.Path = $fullPath
        $this.FileName = [System.IO.Path]::GetFileName($fullPath)
        $this.Language = Get-SRxMonacoLanguage -Path $fullPath
        if ($null -ne $doc) {
            $this.EncodingName = [string]$doc.EncodingName
            $this.HasBom = [bool]$doc.HasBom
            $this.NewLine = [string]$doc.NewLine
        }
    }

    # Asynchronous save (content is pulled from Monaco). $true = started / nothing to do.
    [bool] Save() {
        if ($null -eq $this._collection) { return $false }
        return $this._collection.Save($this)
    }

    # Close with "unsaved changes" prompt. $true = closed now, $false = prompt shown.
    [bool] Close() {
        if ($null -eq $this._collection) { return $true }
        return $this._collection.Close($this, $false)
    }

    # Close; -force $true discards unsaved changes without prompting.
    [bool] Close([bool]$force) {
        if ($null -eq $this._collection) { return $true }
        return $this._collection.Close($this, $force)
    }

    [void] Select() {
        if ($null -ne $this._collection) { $this._collection.Select($this) }
    }

    # Iteration 10.1 - $true = mode applied; $false = refused (see LastError)
    [bool] SetReadOnly([bool]$readOnly) {
        if ($null -eq $this._collection) { $this.IsReadOnly = $readOnly; return $true }
        return $this._collection.SetReadOnly($this, $readOnly)
    }

    # Synchronous core of Save(): writes $text to disk with the original
    # encoding / BOM / line endings. Returns Write-SRxEditorFile's result.
    [object] CommitText([string]$text) {
        $r = Write-SRxEditorFile -Path $this.Path -Text $text -EncodingName $this.EncodingName -HasBom $this.HasBom -NewLine $this.NewLine
        if ($r.Success) {
            $this.LastError = ''
            $this.LastSaved = [datetime]::Now
        }
        else {
            $this.LastError = [string]$r.Error
        }
        return $r
    }

    [string] DisplayTitle() {
        if ($this.IsDirty) { return ($this.FileName + ' ' + [string][char]0x25CF) }
        return $this.FileName
    }

    [string] ToString() { return $this.Path }
}
#endregion

#region ---- SRxMonacoEditorCollection -----------------------------------------
class SRxMonacoEditorCollection {
    [object] $Owner = $null
    [object] $Selected = $null
    [string] $LastError = ''
    hidden [System.Collections.Generic.List[object]] $_items = $null
    hidden [hashtable] $_byPath = $null    # case-insensitive full path -> editor
    hidden [hashtable] $_byId = $null
    hidden [hashtable] $_pending = $null   # requestId -> @{ Editor; After }

    SRxMonacoEditorCollection([object]$owner) {
        $this.Owner = $owner
        $this._items = New-Object 'System.Collections.Generic.List[object]'
        $this._byPath = @{}
        $this._byId = @{}
        $this._pending = @{}
    }

    # ---------------------------------------------------------------- queries
    [int] Count() { return $this._items.Count }

    [object[]] Items() { return $this._items.ToArray() }

    [object] Get([string]$path) {
        if (-not (Test-SRxAbsolutePath -Path $path)) { return $null }
        try { $full = [System.IO.Path]::GetFullPath($path) } catch { return $null }
        if ($this._byPath.ContainsKey($full)) { return $this._byPath[$full] }
        return $null
    }

    [object] GetById([string]$id) {
        if ([string]::IsNullOrEmpty($id)) { return $null }
        if ($this._byId.ContainsKey($id)) { return $this._byId[$id] }
        return $null
    }

    [int] IndexOf([object]$editor) { return $this._items.IndexOf($editor) }

    [bool] HasUnsavedChanges() {
        foreach ($ed in $this._items) { if ($ed.IsDirty) { return $true } }
        return $false
    }

    [int] PendingRequestCount() { return $this._pending.Count }

    # ---------------------------------------------------------------- Open
    # Opens (or re-selects) the file at an ABSOLUTE path. Returns the editor,
    # or $null with .LastError set.
    # 1-arg form: a NEW editor is read/write; an already-open one keeps its mode.
    [object] Open([string]$path) { return $this.openInternal($path, $null) }

    # Iteration 10.1 - $readOnly: $true = read-only, $false = read/write.
    # Applied to an already-open editor too (refused while it has unsaved
    # changes: the editor is still returned/selected, LastError is set).
    [object] Open([string]$path, [bool]$readOnly) { return $this.openInternal($path, $readOnly) }

    hidden [object] openInternal([string]$path, [object]$readOnly) {
        $this.LastError = ''
        if (-not (Test-SRxAbsolutePath -Path $path)) {
            $this.LastError = "Open: path must be absolute: '$path'."
            $this.notifyStatus($this.LastError)
            return $null
        }
        $full = $null
        try { $full = [System.IO.Path]::GetFullPath($path) }
        catch {
            $this.LastError = "Open: invalid path '$path': $($_.Exception.Message)"
            $this.notifyStatus($this.LastError)
            return $null
        }

        if ($this._byPath.ContainsKey($full)) {
            $existing = $this._byPath[$full]
            $this.Select($existing)
            if ($null -ne $readOnly) { [void]$this.SetReadOnly($existing, [bool]$readOnly) }
            return $existing
        }

        if (-not [System.IO.File]::Exists($full)) {
            $this.LastError = "Open: file not found: '$full'."
            $this.notifyStatus($this.LastError)
            return $null
        }

        $doc = $null
        try { $doc = Read-SRxEditorFile -Path $full }
        catch {
            $this.LastError = "Open: cannot read '$full': $($_.Exception.Message)"
            $this.notifyStatus($this.LastError)
            return $null
        }

        $ed = [SRxMonacoEditor]::new($this, $full, $doc)
        if ($null -ne $readOnly) { $ed.IsReadOnly = [bool]$readOnly }
        $this._items.Add($ed)
        $this._byPath[$full] = $ed
        $this._byId[$ed.Id] = $ed

        try { if ($this.Owner) { $this.Owner.editorOpened($ed, [string]$doc.Text) } }
        catch { Write-Host "SRxMonacoEditorCollection.Open/editorOpened - $($_.Exception.Message)" }

        $this.Select($ed)
        return $ed
    }

    # ---------------------------------------------------------------- Select
    [void] Select([object]$editor) { $this.selectInternal($editor, $true) }

    hidden [void] selectInternal([object]$editor, [bool]$show) {
        if ($null -ne $editor -and ($editor.IsClosed -or -not $this._byId.ContainsKey($editor.Id))) { return }
        $this.Selected = $editor
        try { if ($this.Owner) { $this.Owner.editorSelected($editor, $show) } }
        catch { Write-Host "SRxMonacoEditorCollection.Select - $($_.Exception.Message)" }
    }

    # ---------------------------------------------------------------- Save
    [bool] Save([object]$editor) { return $this.beginSave($editor, '') }

    hidden [bool] beginSave([object]$editor, [string]$after) {
        if ($null -eq $editor -or $editor.IsClosed) { return $false }

        # Iteration 10.1 guard: a read-only editor is never saved
        if ($editor.IsReadOnly) {
            $editor.LastError = "Save: '$($editor.FileName)' is open read-only."
            $this.LastError = $editor.LastError
            if ($after -eq 'close') { $this.removeEditor($editor) }
            return $false
        }

        if (-not $editor.IsDirty) {
            if ($after -eq 'close') { $this.removeEditor($editor) }
            return $true
        }

        # already waiting for this editor's content -> just upgrade the follow-up action
        foreach ($k in @($this._pending.Keys)) {
            $p = $this._pending[$k]
            if ($p.Editor -eq $editor) {
                if ($after -eq 'close') { $p.After = 'close' }
                return $true
            }
        }

        $req = [guid]::NewGuid().ToString('N')
        $this._pending[$req] = @{ Editor = $editor; After = $after }
        $editor.IsSaving = $true
        $this.notifyState($editor)
        try {
            if ($this.Owner) { $this.Owner.requestContent($editor, $req) }
        }
        catch {
            $this._pending.Remove($req)
            $editor.IsSaving = $false
            $editor.LastError = "Save: content request failed: $($_.Exception.Message)"
            $this.LastError = $editor.LastError
            $this.notifyState($editor)
            $this.notifyStatus($editor.LastError)
            return $false
        }
        return $true
    }

    # Called with Monaco's reply to requestContent(). Unknown/stale ids are ignored.
    [void] CompleteContentRequest([string]$requestId, [string]$text, [int]$version) {
        if ([string]::IsNullOrEmpty($requestId) -or -not $this._pending.ContainsKey($requestId)) { return }
        $p = $this._pending[$requestId]
        $this._pending.Remove($requestId)
        $ed = $p.Editor
        $ed.IsSaving = $false
        if ($ed.IsClosed) { return }
        if ($ed.IsReadOnly) { $this.notifyState($ed); return }   # Iteration 10.1 guard

        $r = $ed.CommitText($text)
        if (-not $r.Success) {
            $this.LastError = "Save failed for '$($ed.FileName)': $($r.Error)"
            $this.notifyState($ed)
            $this.notifyStatus($this.LastError)
            return     # a pending 'close' is cancelled - unsaved edits stay open
        }

        $ed.IsDirty = $false   # provisional; Monaco confirms via SetDirty()
        try { if ($this.Owner) { $this.Owner.markSaved($ed, $version) } }
        catch { Write-Host "SRxMonacoEditorCollection.markSaved - $($_.Exception.Message)" }
        $this.notifyState($ed)
        $this.notifyStatus("Saved '$($ed.Path)'.")

        if ([string]$p.After -eq 'close') { $this.removeEditor($ed) }
    }

    [void] FailContentRequest([string]$requestId, [string]$message) {
        if ([string]::IsNullOrEmpty($requestId) -or -not $this._pending.ContainsKey($requestId)) { return }
        $p = $this._pending[$requestId]
        $this._pending.Remove($requestId)
        $ed = $p.Editor
        $ed.IsSaving = $false
        $ed.LastError = "Save failed: $message"
        $this.LastError = $ed.LastError
        $this.notifyState($ed)
        $this.notifyStatus($ed.LastError)
    }

    # Monaco -> host dirty notifications
    [void] SetDirty([string]$id, [bool]$dirty) {
        $ed = $this.GetById($id)
        if ($null -eq $ed) { return }
        if ($dirty -and $ed.IsReadOnly) { return }   # Iteration 10.1 guard
        if ($ed.IsDirty -eq $dirty) { return }
        $ed.IsDirty = $dirty
        $this.notifyState($ed)
    }

    # ---------------------------------------------------------------- Read-only (Iteration 10.1)
    [bool] SetReadOnly([object]$editor, [bool]$readOnly) {
        if ($null -eq $editor -or $editor.IsClosed) { return $false }
        if ($editor.IsReadOnly -eq $readOnly) { return $true }
        if ($readOnly -and ($editor.IsDirty -or $editor.IsSaving)) {
            $editor.LastError = "SetReadOnly: '$($editor.FileName)' has unsaved changes - save or discard them first."
            $this.LastError = $editor.LastError
            $this.notifyStatus($editor.LastError)
            return $false
        }
        $editor.IsReadOnly = $readOnly
        try { if ($this.Owner) { $this.Owner.editorReadOnlyChanged($editor) } }
        catch { Write-Host "SRxMonacoEditorCollection.editorReadOnlyChanged - $($_.Exception.Message)" }
        $this.notifyState($editor)
        return $true
    }

    # ---------------------------------------------------------------- Close
    # $true = closed now; $false = unsaved changes -> prompt shown (answer via ResolvePrompt)
    [bool] Close([object]$editor, [bool]$force) {
        if ($null -eq $editor -or $editor.IsClosed) { return $true }
        if (-not $force -and $editor.IsDirty -and -not $editor.IsReadOnly) {
            $this.Select($editor)
            try { if ($this.Owner) { $this.Owner.promptUnsaved($editor) } }
            catch { Write-Host "SRxMonacoEditorCollection.promptUnsaved - $($_.Exception.Message)" }
            return $false
        }
        $this.removeEditor($editor)
        return $true
    }

    # Answer to promptUnsaved(): 'save' | 'discard' | 'cancel'
    [void] ResolvePrompt([object]$editor, [string]$choice) {
        if ($null -eq $editor -or $editor.IsClosed) { return }
        switch ($choice) {
            'save'    { [void]$this.beginSave($editor, 'close') }
            'discard' { $this.removeEditor($editor) }
            default   { }
        }
    }

    # Closes everything WITHOUT prompting (host shutdown).
    [void] Clear() {
        foreach ($ed in @($this._items.ToArray())) {
            $ed.IsClosed = $true
        }
        $this._items.Clear()
        $this._byPath.Clear()
        $this._byId.Clear()
        $this._pending.Clear()
        $this.Selected = $null
    }

    hidden [void] removeEditor([object]$editor) {
        $idx = $this._items.IndexOf($editor)
        if ($idx -lt 0) { return }
        $wasSelected = ($this.Selected -eq $editor)

        $this._items.RemoveAt($idx)
        $this._byPath.Remove($editor.Path)
        $this._byId.Remove($editor.Id)
        foreach ($k in @($this._pending.Keys)) {
            if ($this._pending[$k].Editor -eq $editor) { $this._pending.Remove($k) }
        }
        $editor.IsClosed = $true
        $editor.IsSaving = $false

        $showNext = $true
        try {
            if ($this.Owner) {
                $r = $this.Owner.editorClosed($editor)
                if ($r -is [bool]) { $showNext = $r }
            }
        }
        catch { Write-Host "SRxMonacoEditorCollection.editorClosed - $($_.Exception.Message)" }
        $this.notifyStatus("Closed '$($editor.Path)'.")

        if ($wasSelected) {
            if ($this._items.Count -gt 0) {
                $next = $this._items[[Math]::Min($idx, $this._items.Count - 1)]
                $this.selectInternal($next, $showNext)
            }
            else {
                $this.selectInternal($null, $showNext)
            }
        }
    }

    # ---------------------------------------------------------------- owner helpers
    hidden [void] notifyState([object]$editor) {
        try { if ($this.Owner) { $this.Owner.editorStateChanged($editor) } }
        catch { Write-Host "SRxMonacoEditorCollection.editorStateChanged - $($_.Exception.Message)" }
    }

    hidden [void] notifyStatus([string]$text) {
        try { if ($this.Owner) { $this.Owner.status($text) } } catch { }
    }
}
#endregion

function New-SRxMonacoEditorCollection {
    <#
        .SYNOPSIS
        Creates an editor collection bound to an owner implementing the
        OWNER CONTRACT (see module header).
    #>

    [CmdletBinding()]
    [OutputType([object])]
    param([Parameter(Mandatory)][object]$Owner)
    return [SRxMonacoEditorCollection]::new($Owner)
}

Export-ModuleMember -Function New-SRxMonacoEditorCollection, Read-SRxEditorFile, Write-SRxEditorFile, Get-SRxEditorEncoding, Get-SRxMonacoLanguage, ConvertTo-SRxJsonMessage, ConvertFrom-SRxJsonMessage, Test-SRxAbsolutePath