lib/previewsession.ps1

# previewsession.ps1 -- a preview written to a whole file, recorded on disk.
#
# The Windows Terminal picker, the tuner and the WezTerm picker preview by
# rewriting a file as you arrow: settings.json, or the generated WezTerm module.
# Each kept its only undo in memory -- the file's text as it opened -- and
# wrote every preview, and Esc, without looking at the file first. So:
#
# * a tab closed mid-pick ends pwsh without its finally (SIGHUP on macOS and
# Linux; on Windows a tab close races the finally, and Stop-Process or a
# power loss skips it), leaving a preview nothing on disk names, and the
# next command's rolling .bak then copies that preview over the user's
# only copy;
# * a change made to the file while the picker is open -- Windows Terminal's
# Settings UI, Settings Sync, another tstyles command -- was replaced by
# the next preview, or by Esc.
#
# This is the VS Code preview session (lib/vscodewriter.ps1) ported to whole
# files. Before the first preview is written, a copy of the file and a record
# naming the session go to <DataRoot>/.preview-records. Every write first
# checks that the file still holds what the session last left in it. Esc puts
# back the bytes it found, or, when the file changed, takes the preview back
# out of it (Undo-WTSettingsPreview). And every settings.json writer, and the
# next picker for WezTerm, first puts back a preview a closed session left
# (Restore-AbandonedFilePreview).

function Get-FilePreviewRecordPath {
    # The record and the copy of the file it found, keyed on kind and path:
    # @{ Record; Original }. Lower-cased on Windows only, where paths are.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidateSet('WindowsTerminal', 'WezTerm')][string]$Kind,
        [Parameter(Mandatory)][string]$Path
    )
    $full = [System.IO.Path]::GetFullPath($Path)
    if ((Get-TStylesPlatform) -eq 'Windows') { $full = $full.ToLowerInvariant() }
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $hex = -join ($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($full)) | ForEach-Object { $_.ToString('x2') })
    } finally { $sha.Dispose() }
    $prefix = if ($Kind -eq 'WindowsTerminal') { 'wt' } else { 'wezterm' }
    $dir = Join-Path $script:TStylesDataRoot '.preview-records'
    $stem = Join-Path $dir ('{0}-{1}' -f $prefix, $hex.Substring(0, 16))
    return @{ Dir = $dir; Record = "$stem.json"; Original = "$stem.original" }
}

function Get-FilePreviewTextHash {
    param([AllowNull()][string]$Text)
    if ($null -eq $Text) { return $null }
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        return (-join ($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Text)) | ForEach-Object { $_.ToString('x2') }))
    } finally { $sha.Dispose() }
}

function Read-FilePreviewText {
    # The file as text, UTF-8 with any BOM dropped (how every snapshot here has
    # always been read), or $null when it does not exist. Shared for read so a
    # file another process has open does not fail the check.
    param([Parameter(Mandatory)][string]$Path)
    if (-not [System.IO.File]::Exists($Path)) { return $null }
    $fs = [System.IO.FileStream]::new($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read,
                                      [System.IO.FileShare]::ReadWrite)
    try {
        $sr = [System.IO.StreamReader]::new($fs, [System.Text.UTF8Encoding]::new($false), $true)
        try { return $sr.ReadToEnd() } finally { $sr.Dispose() }
    } finally { $fs.Dispose() }
}

function Read-FilePreviewRecord {
    # @{ Exists; Valid; Data }. A record that cannot be parsed, or names another
    # schema, is Exists and not Valid: left alone rather than guessed at.
    param([Parameter(Mandatory)][string]$Kind, [Parameter(Mandatory)][string]$Path)
    $p = Get-FilePreviewRecordPath -Kind $Kind -Path $Path
    if (-not [System.IO.File]::Exists($p.Record)) { return @{ Exists = $false; Valid = $false; Data = $null } }
    try {
        $d = [System.IO.File]::ReadAllText($p.Record, [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json
    } catch { return @{ Exists = $true; Valid = $false; Data = $null } }
    $ok = $d -and "$($d.schemaVersion)" -eq '1' -and $d.session -and "$($d.kind)" -eq $Kind
    return @{ Exists = $true; Valid = [bool]$ok; Data = $d }
}

function Test-FilePreviewRecordOurs {
    param([Parameter(Mandatory)][hashtable]$Session)
    $r = Read-FilePreviewRecord -Kind $Session.Kind -Path $Session.Path
    return ($r.Valid -and "$($r.Data.session)" -ceq $Session.Id)
}

function Remove-FilePreviewRecord {
    <#
    .SYNOPSIS
    Delete the record and the copy for one file -- only the ones naming
    -SessionId, when given.

    .DESCRIPTION
    The WezTerm apply and reset call it with no -SessionId before they write the
    module: a preview can be byte-identical to an explicit apply (most bundled
    styles share a font and padding), and a record that survived the apply
    would let a later recovery, or the picker's Esc, put the old module back
    over it.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Kind,
        [Parameter(Mandatory)][string]$Path,
        [string]$SessionId
    )
    $p = Get-FilePreviewRecordPath -Kind $Kind -Path $Path
    if ($SessionId) {
        $r = Read-FilePreviewRecord -Kind $Kind -Path $Path
        if (-not ($r.Valid -and "$($r.Data.session)" -ceq $SessionId)) { return }
    }
    foreach ($f in $p.Record, $p.Original) {
        if ([System.IO.File]::Exists($f)) { Remove-Item -LiteralPath $f -Force -ErrorAction SilentlyContinue }
    }
}

function New-FilePreviewSession {
    <#
    .SYNOPSIS
    A preview session for one whole file. Reads it once; writes nothing.

    .DESCRIPTION
    OriginalText is the snapshot every preview is built from, read once. A
    record already on disk for this file -- a recovery that failed, so it holds
    the only copy of what a closed session found -- makes this session's
    writes refuse ('Blocked'), so its copy is never written over.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidateSet('WindowsTerminal', 'WezTerm')][string]$Kind,
        [Parameter(Mandatory)][string]$Path,
        [Parameter(Mandatory)][ValidateSet('picker', 'tuner')][string]$By,
        [string]$TargetName
    )
    $existed = [System.IO.File]::Exists($Path)
    $text = $null
    if ($existed) {
        try { $text = Read-FilePreviewText -Path $Path }
        catch { throw ("Could not read {0}: {1}" -f $Path, $_.Exception.GetBaseException().Message) }
    }
    return @{
        Kind            = $Kind
        Path            = $Path
        By              = $By
        TargetName      = $TargetName
        Id              = [guid]::NewGuid().ToString('n')
        OriginalExisted = $existed
        OriginalText    = $text
        LastWrittenText = $null
        Recorded        = $false
        Changed         = $false
        Blocked         = (Read-FilePreviewRecord -Kind $Kind -Path $Path).Exists
    }
}

function Write-FilePreview {
    <#
    .SYNOPSIS
    Write one preview through the session. Returns written | current | changed
    | failed, and never throws.

    .DESCRIPTION
    'changed' is sticky: once the file holds something other than what the
    session last left in it, nothing more is written into it, and Esc and Enter
    work from the file as it is. The record and the copy are saved BEFORE the
    file is written, so a session that ends at any point has left a record of
    what is on disk; one that cannot be saved writes no preview.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][hashtable]$Session,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text
    )
    if (-not $Session) { return 'failed' }
    if ($Session.Changed) { return 'changed' }
    if ($Session.Blocked) { return 'failed' }
    try {
        if ($Session.Recorded -and -not (Test-FilePreviewRecordOurs -Session $Session)) {
            $Session.Changed = $true
            return 'changed'
        }
        $now = Read-FilePreviewText -Path $Session.Path
        $expected = $Session.LastWrittenText
        if ($null -eq $expected) { $expected = $Session.OriginalText }
        if ($now -cne $expected) {
            $Session.Changed = $true
            return 'changed'
        }
        if ($Text -ceq $now) { return 'current' }

        $p = Get-FilePreviewRecordPath -Kind $Session.Kind -Path $Session.Path
        if (-not $Session.Recorded) {
            if (-not [System.IO.Directory]::Exists($p.Dir)) { [void][System.IO.Directory]::CreateDirectory($p.Dir) }
            if ($Session.OriginalExisted) {
                Copy-Item -LiteralPath $Session.Path -Destination $p.Original -Force -ErrorAction Stop
            }
        }
        $record = [ordered]@{
            schemaVersion = 1
            kind          = $Session.Kind
            path          = [System.IO.Path]::GetFullPath($Session.Path)
            session       = $Session.Id
            by            = $Session.By
            existed       = [bool]$Session.OriginalExisted
            written       = @((Get-FilePreviewTextHash -Text $now), (Get-FilePreviewTextHash -Text $Text) | Where-Object { $_ })
        }
        Write-SettingsAtomic -Path $p.Record -Json (ConvertTo-Json -InputObject $record -Depth 4)
        $Session.Recorded = $true

        if ($Session.Kind -eq 'WindowsTerminal') {
            Write-SettingsAtomic -Path $Session.Path -Json $Text
        } else {
            if ((Write-WezTermModuleText -Path $Session.Path -Text $Text) -eq 'failed') { return 'failed' }
        }
        $Session.LastWrittenText = $Text
        return 'written'
    } catch { return 'failed' }
}

function Restore-FilePreview {
    <#
    .SYNOPSIS
    Esc: put back what the session found, or take its preview out of a file
    that changed. Returns @{ Event='restore'; Status; ... }, never throws.

    .DESCRIPTION
    Status is none | restored | merged | left | failed.
      none nothing of the session's is in the file; nothing written
      restored the file still held the preview, so the bytes it found went back
      merged the file changed (Windows Terminal): the preview's keys were
                taken out of it as it is now, and Kept names a key changed again
      left another tstyles command took the session over, or (WezTerm)
                another writer rewrote the module, which wins
      failed nothing could be put back; the record and copy stay for the
                next command. Arm says which step failed.
    -End drops the record and the copy, but only if the record names this
    session.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][hashtable]$Session,
        [switch]$End
    )
    $out = @{ Event = 'restore'; Status = 'none'; Kind = $null; By = $null; Path = $null; Kept = @(); Reason = $null; Arm = $null }
    if (-not $Session) { return $out }
    $out.Kind = $Session.Kind; $out.By = $Session.By; $out.Path = $Session.Path

    if ($null -eq $Session.LastWrittenText) {
        if ($End) { Remove-FilePreviewRecord -Kind $Session.Kind -Path $Session.Path -SessionId $Session.Id }
        return $out
    }
    try {
        if ($Session.Recorded -and -not (Test-FilePreviewRecordOurs -Session $Session)) {
            $out.Status = 'left'
            $Session.LastWrittenText = $null
            return $out
        }
        $now = Read-FilePreviewText -Path $Session.Path
        if ($now -ceq $Session.LastWrittenText) {
            $out.Arm = 'restore'
            if ($Session.Kind -eq 'WindowsTerminal') {
                Write-SettingsAtomic -Path $Session.Path -Json $Session.OriginalText
            } elseif ($Session.OriginalExisted) {
                if ((Write-WezTermModuleText -Path $Session.Path -Text $Session.OriginalText) -eq 'failed') {
                    throw "the module could not be written"
                }
            } else {
                Remove-Item -LiteralPath $Session.Path -Force -ErrorAction Stop
            }
            $out.Status = 'restored'
        } elseif ($Session.Kind -eq 'WindowsTerminal') {
            $out.Arm = 'merge'
            $u = Undo-WTSettingsPreview -OriginalJson $Session.OriginalText -PreviewJson $Session.LastWrittenText `
                    -CurrentJson $now -TargetName $Session.TargetName
            if (@($u.Undone).Count -gt 0) {
                Write-SettingsAtomic -Path $Session.Path -Json $u.Text
                $out.Status = 'merged'
            } else {
                # Every key the preview set was changed again: nothing of it
                # is left to take out.
                $out.Status = 'left'
            }
            $out.Kept = @($u.Kept)
        } else {
            $out.Status = 'left'
        }
        $Session.LastWrittenText = $null
        if ($End) { Remove-FilePreviewRecord -Kind $Session.Kind -Path $Session.Path -SessionId $Session.Id }
    } catch {
        $out.Status = 'failed'
        $out.Reason = $_.Exception.GetBaseException().Message
    }
    return $out
}

function Complete-FilePreview {
    <#
    .SYNOPSIS
    Enter: keep the preview on disk, or merge the chosen style into a file that
    changed. Returns @{ Event='commit'; Status done | merged | failed; ... }.

    .DESCRIPTION
    When nobody else wrote the file, the commit is the chosen style's preview
    as -Merge builds it from the text the session found: 'done' when the file
    already holds it (the last preview was that style), and written, still
    'done', when it does not -- the last preview write failed, or the session
    could write none. 'done' never means "nothing happened to a style the
    picker then calls applied". When the file changed, the session's own
    preview is taken out of it as it is now (when the record is still this
    session's) and -Merge is run over the result: 'merged'. Without -Merge (the
    picker's finally after an error), a file that still holds the preview is
    kept as the commit; a changed one has the preview taken out. The record is
    dropped only when it names this session.

    WezTerm: the publish that follows Enter writes the module itself, so this
    only drops the session's record.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][hashtable]$Session,
        [scriptblock]$Merge
    )
    $out = @{ Event = 'commit'; Status = 'done'; Kind = $null; By = $null; Path = $null; Kept = @(); Reason = $null; Style = $null }
    if (-not $Session) { return $out }
    $out.Kind = $Session.Kind; $out.By = $Session.By; $out.Path = $Session.Path
    if ($Session.Kind -eq 'WezTerm') {
        Remove-FilePreviewRecord -Kind $Session.Kind -Path $Session.Path -SessionId $Session.Id
        $Session.LastWrittenText = $null
        return $out
    }
    try {
        $ours = $Session.Recorded -and (Test-FilePreviewRecordOurs -Session $Session)
        $now = Read-FilePreviewText -Path $Session.Path
        $expected = $Session.LastWrittenText
        if ($null -eq $expected) { $expected = $Session.OriginalText }
        if ((-not $Session.Recorded -or $ours) -and $now -ceq $expected) {
            if ($Merge) {
                $chosen = & $Merge $Session.OriginalText
                if ($null -eq $chosen) { $chosen = $Session.OriginalText }
                if ($chosen -cne $now) { Write-SettingsAtomic -Path $Session.Path -Json $chosen }
            }
            Remove-FilePreviewRecord -Kind $Session.Kind -Path $Session.Path -SessionId $Session.Id
            $Session.LastWrittenText = $null
            return $out
        }

        $base = $now
        if ($ours -and $null -ne $Session.LastWrittenText) {
            $u = Undo-WTSettingsPreview -OriginalJson $Session.OriginalText -PreviewJson $Session.LastWrittenText `
                    -CurrentJson $now -TargetName $Session.TargetName
            if (@($u.Undone).Count -gt 0) { $base = $u.Text }
            $out.Kept = @($u.Kept)
        }
        $final = $base
        if ($Merge) {
            $m = & $Merge $base
            if ($null -ne $m) { $final = $m }
        }
        if ($final -cne $now) {
            Write-SettingsAtomic -Path $Session.Path -Json $final
            $out.Status = 'merged'
        }
        Remove-FilePreviewRecord -Kind $Session.Kind -Path $Session.Path -SessionId $Session.Id
        $Session.LastWrittenText = $null
    } catch {
        $out.Status = 'failed'
        $out.Reason = $_.Exception.GetBaseException().Message
    }
    return $out
}

function Restore-AbandonedFilePreview {
    <#
    .SYNOPSIS
    Put back a preview a closed session left in a file, before reading it.
    Returns @{ Event='recover'; Status none | restored | kept | failed; ... }.

    .DESCRIPTION
    Called by every settings.json writer before it reads the file or rolls its
    .bak, and by the WezTerm picker before it opens its session.

      restored the file holds one of the session's previews: the copy of what
                it found goes back, and the record goes
      kept settings.json changed since: left as it is, and the copy is
                moved beside it under a name nothing else writes
      none no record, one that cannot be read (left alone), or nothing to
                put back
      failed the copy could not be put back; both stay for the next command

    No liveness check: a picker still open in another tab is treated as closed,
    and its own Esc then finds the record gone and leaves the file ('left').
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidateSet('WindowsTerminal', 'WezTerm')][string]$Kind,
        [Parameter(Mandatory)][string]$Path
    )
    $out = @{ Event = 'recover'; Status = 'none'; Kind = $Kind; By = $null; Path = $Path; Backup = $null; Copy = $null; Reason = $null }
    $p = Get-FilePreviewRecordPath -Kind $Kind -Path $Path
    $r = Read-FilePreviewRecord -Kind $Kind -Path $Path
    if (-not $r.Valid) { return $out }
    $d = $r.Data
    $out.By = "$($d.by)"
    $full = [System.IO.Path]::GetFullPath($Path)
    $cmp = if ((Get-TStylesPlatform) -eq 'Windows') { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
    if (-not [string]::Equals("$($d.path)", $full, $cmp)) { return $out }

    try {
        $now = Read-FilePreviewText -Path $Path
        $copy = $null
        if ([System.IO.File]::Exists($p.Original)) { $copy = Read-FilePreviewText -Path $p.Original }
        $existed = [bool]$d.existed
        if (($existed -and $null -eq $copy) -or ($Kind -eq 'WindowsTerminal' -and -not $existed)) {
            Remove-FilePreviewRecord -Kind $Kind -Path $Path
            return $out
        }
        if (($null -eq $now -and $null -eq $copy) -or ($null -ne $now -and $now -ceq $copy)) {
            Remove-FilePreviewRecord -Kind $Kind -Path $Path
            return $out
        }
        $written = @(@($d.written) | ForEach-Object { "$_" })
        if ($null -ne $now -and $written -ccontains (Get-FilePreviewTextHash -Text $now)) {
            if ($Kind -eq 'WindowsTerminal') {
                Write-SettingsAtomic -Path $Path -Json $copy
            } elseif ($existed) {
                if ((Write-WezTermModuleText -Path $Path -Text $copy) -eq 'failed') { throw 'the module could not be written' }
            } else {
                Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
            }
            Remove-FilePreviewRecord -Kind $Kind -Path $Path
            $out.Status = 'restored'
            return $out
        }
        if ($Kind -eq 'WindowsTerminal' -and $null -ne $copy) {
            # Not "$Path.bak-<timestamp>": apply.ps1 copies to exactly that name,
            # without -Force, milliseconds after its own recovery.
            $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss', [cultureinfo]::InvariantCulture)
            $dest = "$Path.preview-$stamp"
            $n = 1
            while ([System.IO.File]::Exists($dest)) { $dest = "$Path.preview-$stamp-$n"; $n++ }
            Move-Item -LiteralPath $p.Original -Destination $dest -ErrorAction Stop
            Remove-FilePreviewRecord -Kind $Kind -Path $Path
            $out.Status = 'kept'
            $out.Backup = $dest
            return $out
        }
        Remove-FilePreviewRecord -Kind $Kind -Path $Path
    } catch {
        $out.Status = 'failed'
        $out.Reason = $_.Exception.GetBaseException().Message
        $out.Copy = $p.Original
    }
    return $out
}

function Show-FilePreviewResult {
    # What a recovery, an Esc or an Enter did, in the user's terms. Quiet when
    # there is nothing to say: none, a clean Esc, a clean Enter.
    [CmdletBinding()]
    param([AllowNull()]$Result)
    if (-not $Result) { return }
    $by = if ($Result.By) { $Result.By } else { 'picker' }
    $wez = ($Result.Kind -eq 'WezTerm')
    switch ("$($Result.Event)/$($Result.Status)") {
        'recover/restored' {
            if ($wez) {
                Write-Host " WezTerm's module held a preview from a tstyles picker that was closed without Enter or" -ForegroundColor Yellow
                Write-Host " Esc (or is still open in another tab), so the module it found was put back first." -ForegroundColor Yellow
            } else {
                Write-Host (" settings.json held a preview from a tstyles {0} that was closed without Enter or" -f $by) -ForegroundColor Yellow
                Write-Host " Esc (or is still open in another tab), so the file it found was put back first." -ForegroundColor Yellow
            }
        }
        'recover/kept' {
            Write-Host (" settings.json holds a preview from a tstyles {0} that was closed without Enter or Esc," -f $by) -ForegroundColor Yellow
            Write-Host (" and it has changed since, so it was left as it is. The file as that {0} found it is" -f $by) -ForegroundColor Yellow
            Write-Host (" kept at {0}." -f $Result.Backup) -ForegroundColor Yellow
        }
        'recover/failed' {
            Write-Host (" {0} holds a preview from a tstyles {1} that was closed without Enter or Esc, and" -f $Result.Path, $by) -ForegroundColor Yellow
            Write-Host (" putting back the file it found failed: {0}" -f $Result.Reason) -ForegroundColor Yellow
            $again = if ($wez) { 'the next tstyles picker tries again' } else { 'the next tstyles command that writes it tries again' }
            Write-Host (" That file is kept at {0}; {1}." -f $Result.Copy, $again) -ForegroundColor Yellow
        }
        'restore/merged' {
            Write-Host (" settings.json changed while the {0} was open, so the preview was taken back out of it" -f $by) -ForegroundColor Yellow
            Write-Host " instead of the file being put back as it was. The change is kept." -ForegroundColor Yellow
            if (@($Result.Kept).Count -gt 0) {
                Write-Host (" Left {0} as the file has them now: they changed after the preview wrote them." -f (@($Result.Kept) -join ', ')) -ForegroundColor DarkGray
            }
        }
        'restore/left' {
            if ($wez) {
                Write-Host " WezTerm's module changed while the picker was open, so it was left as it is now." -ForegroundColor Yellow
            } else {
                Write-Host (" settings.json changed while the {0} was open, so it was left as it is now." -f $by) -ForegroundColor Yellow
            }
        }
        'restore/failed' {
            Write-Host (" Could not put {0} back: {1}" -f $Result.Path, $Result.Reason) -ForegroundColor Red
            $next = if ($wez) { 'the next tstyles picker' } else { 'the next tstyles command that writes it' }
            if ($Result.Arm -eq 'merge') {
                Write-Host (" It changed while it was open and still holds the preview; {0}" -f $next) -ForegroundColor Red
                Write-Host (" keeps the file the {0} found beside it." -f $by) -ForegroundColor Red
            } else {
                Write-Host (" It still holds the preview; {0} puts back the file the {1} found." -f $next, $by) -ForegroundColor Red
            }
        }
        'commit/merged' {
            Write-Host (" settings.json changed while the picker was open, so '{0}' was merged into it as it is" -f $Result.Style) -ForegroundColor DarkGray
            Write-Host (" now, the way tstyles {0} would. The change is kept, except where the style sets the same key." -f $Result.Style) -ForegroundColor DarkGray
        }
        'commit/failed' {
            Write-Host (" Could not write '{0}' into {1}: {2}" -f $Result.Style, $Result.Path, $Result.Reason) -ForegroundColor Yellow
        }
    }
}