Watch-Command.psm1

# Watch-Cmd.psm1
# PowerShell drop-in for Linux’s watch(1)

# ------------------------------------------------------------
# 1. C# P/Invoke Helper in a valid namespace: WatchCommand
# ------------------------------------------------------------
$code = @'
using System;
using System.Text;
using System.Runtime.InteropServices;
 
namespace WatchCommand {
 
  [StructLayout(LayoutKind.Sequential)]
  public static class VT {
 
    [StructLayout(LayoutKind.Sequential)]
    public struct COORD {
      public short X;
      public short Y;
    }
 
    [StructLayout(LayoutKind.Sequential)]
    public struct SMALL_RECT {
      public short Left;
      public short Top;
      public short Right;
      public short Bottom;
    }
 
    [StructLayout(LayoutKind.Sequential)]
    public struct CONSOLE_SCREEN_BUFFER_INFO {
      public COORD dwSize;
      public COORD dwCursorPosition;
      public short wAttributes;
      public SMALL_RECT srWindow;
      public COORD dwMaximumWindowSize;
    }
 
    private const int STD_OUTPUT_HANDLE = -11;
    private const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;
 
    [DllImport("kernel32.dll", SetLastError=true)]
    private static extern IntPtr GetStdHandle(int nStdHandle);
 
    [DllImport("kernel32.dll", SetLastError=true)]
    private static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
 
    [DllImport("kernel32.dll", SetLastError=true)]
    private static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode);
 
    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern bool GetConsoleScreenBufferInfo(IntPtr hConsoleOutput, out CONSOLE_SCREEN_BUFFER_INFO lpConsoleScreenBufferInfo);
     
    public static void Enable() {
      var handle = GetStdHandle(STD_OUTPUT_HANDLE);
      if (GetConsoleMode(handle, out uint mode)) {
        SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
        Console.OutputEncoding = Encoding.UTF8;
      }
    }
 
    public static CONSOLE_SCREEN_BUFFER_INFO GetScreenBufferInfo() {
      var handle = GetStdHandle(STD_OUTPUT_HANDLE);
      CONSOLE_SCREEN_BUFFER_INFO bufferInfo;
      if (GetConsoleScreenBufferInfo(handle, out bufferInfo)) {
        return bufferInfo;
      } else {
        throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
      }
    }
  }
}
'@


# Only compile once per session
if (-not ([type]::GetType("WatchCommand.VT", $false))) {
    Add-Type -TypeDefinition $code -Language CSharp
}

# Expose a simple wrapper
function Enable-AnsiColor {
<#
.SYNOPSIS
  Enables ANSI color support in the console.
 
.DESCRIPTION
  This function uses P/Invoke to enable virtual terminal processing
  in the console, allowing for ANSI escape codes to be interpreted
  correctly, enabling color and other text formatting.
 
.EXAMPLE
  Import-Module Watch-Cmd
  Enable-AnsiColor
#>

  [WatchCommand.VT]::Enable()
}

# Strip ANSI escapes helper
function Remove-AnsiEscapes {
<#
.SYNOPSIS
  Removes ANSI escape sequences from a string.
 
.DESCRIPTION
  This function takes a string and removes any ANSI escape sequences,
  which are used for terminal text formatting like colors.
  Remove-AnsiEscapes TEXT
.EXAMPLE
  Import-Module Watch-Cmd
  Remove-AnsiEscapes "Hello `e[31mWorld`e[0m"
#>

  param([string]$Text)
  return $Text -replace "`e\[[\d;]*[A-Za-z]", ''
}

function Colorize {
<#
.SYNOPSIS
  Colorizes text using ANSI escape codes.
.DESCRIPTION
  This function takes a string and a color name, returning the string
  wrapped in ANSI escape codes for the specified color.
  Colorize COLOR TEXT
.EXAMPLE
  Import-Module Watch-Cmd
  Colorize Green "Hello World"
#>

  param(
    [string]$Color = 'White',
    [string]$Text
  )
  # ANSI 8-color mapping
  $colorCode = @{
    Black   = 30; Red = 31; Green = 32; Yellow = 33;
    Blue    = 34; Magenta = 35; Cyan = 36; White = 37;
    Reset   =0;
  }[$Color]
  if (-not $colorCode) {
    Write-Error "Invalid color: $Color"
    return $Text
  } else {
    return "`e[${colorCode}m$Text`e[0m"
  }
}

function Invoke-ScrollClear {
<#
.SYNOPSIS
  Clears the console screen and scrolls up to the top.
.DESCRIPTION
  This function clears the console screen and scrolls up to the top,
  effectively resetting the view without clearing the entire console.
  It is useful for refreshing the display in a way that mimics the
  behavior of Linux's watch command.
.EXAMPLE
  Import-Module Watch-Cmd
  Invoke-ScrollClear
#>

    # grab host cursor positions
    $bufferInfo = [WatchCommand.VT]::GetScreenBufferInfo()
    $cursor    = $bufferInfo.dwCursorPosition
    
    # write new-line Y times to add space to buffer
    # scroll up so that current row moves to the top
    # Y is calculated after linefeed to the terminal
    # so no index increment
    [Console]::Write("`n" * $cursor.Y)
    if ($rowInView -gt 1) {
        $scrollCount = $rowInView
        [Console]::Write("`e[${scrollCount}T")
    }
    # move cursor to top-left of viewport
    [Console]::Write("`e[1;1H")

    # # clear all lines from cursor downward
    [Console]::Write("`e[J")
}


function Watch-Command {
<#
.SYNOPSIS
  Periodically runs a command and displays its output fullscreen,
  faithfully reproducing Linux’s watch(1) CLI, flags, and behaviors.
 
.DESCRIPTION
  See watch(1) man-page.
  The main difference is in `-p, --precise` flag, which is always ON
  to implement early key handling and rerun on resize.
  Flags (short & long):
    -b, --beep
    -c, --color
    -C, --no-color
    -d, --differences[=permanent]
    -e, --errexit
    -g, --chgexit
    -n, --interval SECONDS
    -p, --precise
    -q, --equexit <cycles>
    -r, --no-rerun
    -s, --shotsdir <dir>
    -t, --no-title
    -w, --no-wrap
    -x, --exec
    -h, --help
    -v, --version
 
  Key Controls:
    Space – refresh immediately
    Q – quit (exit 0)
    S – save “screenshot” under shotsdir
    Ctrl+C – force quit
 
.EXAMPLE
  Import-Module Watch-Cmd
  watch -n1 -d=permanent -- Get-Process
 
#>

  #-----------------------------------------------------------------
  # 1. Parse raw $args exactly like Linux’s POSIX parser
  #-----------------------------------------------------------------
  $rawArgs = [System.Collections.Generic.List[string]]::new()
  $args | ForEach-Object { $rawArgs.Add($_) }

  # Default settings
  $Beep            = $false;   $Color       = $false;
  $NoColor         = $false;   $Differences = $false;
  $PermDiff        = $false;   $ErrExit     = $false;
  $ChgExit         = $false;   # $Precise = $false; It's always precise to provide rerun and early key handling
  $NoRerun         = $false;   $NoTitle     = $false;
  $NoWrap          = $false;   $Exec        = $false;
  $VersionFlag     = $false;   $HelpFlag    = $false;
  $ShotsDir        = $null;    $EquExit      = $null;
  $Interval        = $env:WATCH_INTERVAL ? [double]$env:WATCH_INTERVAL : 2.0;

  # Clamps a value to range [100, 2678400000] (1.0s to 31 days) for sleeping
  function Clamp($v) {
    if ($v -lt 100)   { return 100 }
    if ($v -gt 2678400000) { return 2678400000 }
    return $v
  }

  # Truncates lines to fit within the terminal width
  function NoWrap($l) {
    $w = $env:COLUMNS ? [int]$env:COLUMNS : [Console]::WindowWidth
    $l = $l | ForEach-Object {
      if ($_.Length -gt $w) { $_.Substring(0, $w) } else { $_ }
    }
    return $l
  }
  
  # Flag loop
  :argparse for ($i = 0; $i -lt $rawArgs.Count; $i++) {
    $a = $rawArgs[$i]
    switch -Wildcard ($a) {
      '-h'              { $HelpFlag     = $true; break; }
      '--help'          { $HelpFlag     = $true; break; }
      '-v'              { $VersionFlag  = $true; break; }
      '--version'       { $VersionFlag  = $true; break; }
      '-b'              { $Beep         = $true; break; }
      '--beep'          { $Beep         = $true; break; }
      '-c'              { $Color        = $true; break; }
      '--color'         { $Color        = $true; break; }
      '-C'              { $NoColor      = $true; break; }
      '--no-color'      { $NoColor      = $true; break; }
      '-d'              { $Differences  = $true; break; }
      '-e'              { $ErrExit      = $true; break; }
      '--errexit'       { $ErrExit      = $true; break; }
      '-g'              { $ChgExit      = $true; break; }
      '--chgexit'       { $ChgExit      = $true; break; }
      '-p'              { <#$Precise = $true;#> break; } # Dummy for compatibility
      '--precise'       { <#$Precise = $true;#> break; } # Dummy for compatibility
      '-r'              { $NoRerun      = $true; break; }
      '--no-rerun'      { $NoRerun      = $true; break; }
      '-t'              { $NoTitle      = $true; break; }
      '--no-title'      { $NoTitle      = $true; break; }
      '-w'              { $NoWrap       = $true; break; }
      '--no-wrap'       { $NoWrap       = $true; break; }
      '-x'              { $Exec         = $true; break; }
      '--exec'          { $Exec         = $true; break; }
      '-d'              { $Differences  = $true; break; }
      '--differences'   { $Differences  = $true; break; }
      '-d=*'            {
        $Differences = $true;
        if ($a.Split('=',2)[1].ToLower() -eq 'permanent') { $PermDiff = $true; }
        break;
      }
      '--differences=*' {
        $Differences = $true;
        if ($a.Split('=',2)[1].ToLower() -eq 'permanent') { $PermDiff = $true; }
        break;
      }
      '-n'              {
        if ($i+1 -ge $rawArgs.Count) { Write-Error 'Missing interval'; exit 1; }
        $Interval = Clamp([double]$rawArgs[++$i]); break;
      }
      '--interval'      {
        if ($i+1 -ge $rawArgs.Count) { Write-Error 'Missing interval'; exit 1; }
        $Interval = Clamp([double]$rawArgs[++$i]); break;
      }
      '-n=*'            { $Interval = Clamp([double]$a.Split('=',2)[1]); break; }
      '--interval=*'    { $Interval = Clamp([double]$a.Split('=',2)[1]); break; }
      '-q'              {
        if ($i+1 -ge $rawArgs.Count) { Write-Error 'Missing cycles'; exit 1; }
        $EquExit = [int]$rawArgs[++$i]; break;
      }
      '--equexit'       {
        if ($i+1 -ge $rawArgs.Count) { Write-Error 'Missing cycles'; exit 1; }
        $EquExit = [int]$rawArgs[++$i]; break;
      }
      '-q=*'            { $EquExit = [int]$a.Split('=',2)[1]; break; }
      '--equexit=*'     { $EquExit = [int]$a.Split('=',2)[1]; break; }
      '-s'              {
        if ($i+1 -ge $rawArgs.Count) { Write-Error 'Missing dir'; exit 1; }
        $ShotsDir = $rawArgs[++$i]; break;
      }
      '--shotsdir'      {
        if ($i+1 -ge $rawArgs.Count) { Write-Error 'Missing dir'; exit 1; }
        $ShotsDir = $rawArgs[++$i]; break;
      }
      default           { break argparse }
    }
  }
  
  # Remaining args form the command
  $Command = if ($i -lt $rawArgs.Count) { $rawArgs[$i..($rawArgs.Count-1)] } else { @() }
  
  if ($Color)    {
    Enable-AnsiColor
  }

  if ($HelpFlag) {
    # We'll just print the function help block
    Get-Help Watch-Cmd -Full
    return
  }

  if ($VersionFlag) {
    Write-Output "Watch-Cmd version 1.0.0"
    return
  }

  if (-not $Command.Count) {
    Write-Error "No command specified. Usage: watch [options] command"
    return
  }

  # Ensure shots directory exists
  if ($ShotsDir -and -not (Test-Path $ShotsDir)) {
    New-Item -ItemType Directory -Path $ShotsDir | Out-Null
  }

  #-----------------------------------------------------------------
  # 2. Main loop state
  #-----------------------------------------------------------------
  $prevLines     = @()    # previous output lines
  $baseLines     = @()    # base lines for permanent diff
  $stableCount   = 0      # stable run count
  $lastStart     = $null  # last run start time
  $screenHeader  = ''     # header for the screen
  $currentLines  = @()    # current output lines
  $lines         = @()    # output lines
  $STEP          = 100    # sleep step in ms

  # Invokes a process and returns its exit code and output
  function Invoke-Process {
    param($argsArr)
    $psi = [Diagnostics.ProcessStartInfo]::new()
    if ($Exec) {
      $psi.FileName  = $argsArr[0]
      $psi.Arguments = ($argsArr[1..($argsArr.Length-1)] -join ' ')
    }
    else {
      $psi.FileName  = 'cmd.exe'
      $psi.Arguments = '/c ' + ($argsArr -join ' ')
    }
    $psi.RedirectStandardOutput = $true
    $psi.RedirectStandardError  = $true
    $psi.UseShellExecute        = $false
    $p = [Diagnostics.Process]::Start($psi)
    $out = $p.StandardOutput.ReadToEnd()
    $err = $p.StandardError.ReadToEnd()
    $p.WaitForExit()
    return $p.ExitCode, ($out + ($err ? "`n$err" : ""))
  }

  # Set up console for continues output
  [Console]::CursorVisible = $false
  Invoke-ScrollClear
  $lastWidth  = [Console]::WindowWidth
  $lastHeight = [Console]::WindowHeight

  # Main loop
  :runloop while ($true) {
    # Smooth sleeping loop
    :sleeping while ($null -ne $lastStart) {
      # Determine how long to wait (in ms) until the *next* scheduled run
      # then wakeup if the time has come ($waitTime -lt 0)
      $waitTime = (($Interval * 1000) - ($(Get-Date) - $lastStart).TotalMilliseconds)
      if ($waitTime -lt 0) { $in++; break sleeping }
      # Check for resize
      if ( (-not $NoRerun) -and (([Console]::WindowWidth -ne $lastWidth) -or ([Console]::WindowHeight -ne $lastHeight))) {
        # Size changed and no-rerun is OFF → break to rerun immediately
        $lastWidth  = [Console]::WindowWidth
        $lastHeight = [Console]::WindowHeight
        break sleeping
      }
      # Key handling (Space/Q/S) on-the-fly
      :keycheck while ([Console]::KeyAvailable) {
        $k = [Console]::ReadKey($true)
        switch ($k.Key) {
          'Spacebar' { break sleeping }
          'Q'        { return }
          'S' {
            if ($ShotsDir) {
              $ts   = (Get-Date).ToString('yyyyMMdd_HHmmss')
              $file = Join-Path $ShotsDir "watch_$ts.txt"
              ($screenHeader + "`n" + ($currentLines -join "`n")) |
                Out-File -FilePath $file -Encoding UTF8
            }
            break keycheck
          }
        }
      }
      # Sleep in small chunks so we can detect resize & keypress
      Start-Sleep -Milliseconds $STEP
    }
    
    # Run
    $lastStart = Get-Date
    $runStart = Get-Date
    $exitCode, $raw = Invoke-Process -argsArr $Command
    
    # Prepare output lines from command invocation
    $lines = $raw -split "`r?`n"
    $screenHeader = "Every $([math]::Floor($Interval * 1000))ms: $($Command -join ' ') Started: $(Get-Date -Format u) Elapsed: $([math]::Floor(((Get-Date) - $runStart).TotalMilliseconds))ms Exit: $exitCode"
    $screenSeparator = '─' * $screenHeader.Length
    
    # Truncate instead of wrap
    if ($NoWrap) {
      $lines = NoWrap $lines
      $screenHeader, $screenSeparator = NoWrap $screenHeader, $screenSeparator
    }

    # Clear the console for a fresh display
    [Console]::Write("`e[H`e[J")



    # Header
    if (-not $NoTitle) {
      [Console]::WriteLine($NoColor ? $screenHeader : (Colorize Cyan $screenHeader))
      [Console]::WriteLine($NoColor ? $screenSeparator : (Colorize Cyan $screenSeparator))
    }

    # Diffs
    if ($Differences) {
      if (-not $baseLines.Count) { $baseLines = $lines }
      $ref = $PermDiff ? $baseLines : $prevLines
      Compare-Object -ReferenceObject $ref -DifferenceObject $lines -PassThru |
        ForEach-Object {
          $line, $colorName = $_.SideIndicator -eq '=>' ? "+ $($_.InputObject)", "Green" : ($_.SideIndicator -eq '<=' ? "- $($_.InputObject)", "Red" : "$($_.InputObject)", "Reset")
          $line = $NoWrap ? (NoWrap "$($line)$($_.InputObject)") : "$($line)$($_.InputObject)"
          [Console]::WriteLine($NoColor ? $line : (Colorize $colorName $line))
        }
    }
    # Normal
    else {
      $lines | ForEach-Object {
        $NoColor ? [Console]::WriteLine((Remove-AnsiEscapes $_)) : [Console]::WriteLine($_)
      }
    }

    # Exit on visible change
    if ($ChgExit -and $prevLines -ne $lines) { return }

    # Exit after stable cycles
    if ($EquExit) {
      if ($prevLines -eq $lines) { $stableCount++ }
      else { $stableCount = 0 }
      if ($stableCount -ge $EquExit) { return }
    }

    # Errexit
    if ($ErrExit -and $exitCode -ne 0) {
      [Console]::WriteLine("`n`e[33mCommand failed exit($exitCode). Press any key to quit…`e[0m")
      [Console]::ReadKey($true) | Out-Null
      return
    }

    # Beep
    if ($Beep -and $exitCode -ne 0) {
      [Console]::Beep(750,200)
    }

    # Save for next iteration
    $prevLines    = $lines
    $currentLines = $lines

    # Clear rest of console
    [Console]::Write("`e[J")
  }
}

Export-ModuleMember -Function Watch-Command, Enable-AnsiColor, Remove-AnsiEscapes, Colorize, Invoke-ScrollClear
Set-Alias -Name watch -Value Watch-Command -Scope Global
Set-Alias -Name w -Value Watch-Command -Scope Global