LocationHistory.psm1

#------------------------------------------------------------------------------
# LocationHistory.psm1
#
# (C) 2017 by by Bill Stewart (bstewart@iname.com)
# Special thanks to Keith Hill (cd.psm1 from PSCX)
#
# This module is based on Keith Hill's cd.psm1 module in the PowerShell
# Community Extensions (PSCX) package, with some behavioral changes, fixes,
# additions, and extensions.
#
# The location history stores up to 100 locations (IDs 0-99) and does not
# persist beyond the current PowerShell session.
#
# Exported functions:
#
# * Set-LocationEx is a Set-Location replacement that uses a location history.
# * Get-LocationHistory outputs the location history.
# * Clear-LocationHistory clears the location history.
# * Remove-LocationHistory removes a location from the location history.
#
# Version history:
#
# 1.0.3.0 (2017-02-07)
# * Initial version.
#
# 1.0.5.0 (2017-02-16)
# * Fix: Append to location history correctly.
#
# 1.0.7.0 (2017-07-21)
# * Fix: Don't change stack if setting location fails.
# * Add: -CopyToClipboard parameter.
# * Add: Remove-LocationHistory.
# * Change: Get-LocationHistory outputs formatted objects. (Removed -Raw.)
#------------------------------------------------------------------------------

#requires -version 3

# Remember no more than this many locations.
$MAX_HISTORY_SIZE = 100

# So we can copy to the clipboard.
Add-Type -AssemblyName System.Windows.Forms

# Module-level global variables store the location stacks
$BackwardStack = New-Object Collections.ArrayList
$ForwardStack = New-Object Collections.ArrayList

# Clears the location history
function Clear-LocationHistory {
  <#
  .SYNOPSIS
  Clears the location history.
 
  .DESCRIPTION
  Clears the location history. The location history contains a list of locations visited in the current PowerShell session.
  #>

  [CmdletBinding(SupportsShouldProcess=$true,ConfirmImpact="High")]
  param()
  if ( $PSCmdlet.ShouldProcess("Location history", "Clear") ) {
    $ForwardStack.Clear()
    $BackwardStack.Clear()
  }
}

# Outputs an object based on input hashtables
function Out-Object {
  param(
    [Collections.Hashtable[]] $hashData
  )
  $order = @()
  $result = @{}
  $hashData | ForEach-Object {
    $order += ($_.Keys -as [Array])[0]
    $result += $_
  }
  New-Object PSObject -Property $result | Select-Object $order
}

# Outputs the location history
function Get-LocationHistory {
  <#
  .SYNOPSIS
  Outputs the location history.
 
  .DESCRIPTION
  Outputs the location history. The location history contains a list of locations visited in the current PowerShell session.
  #>

  if ( $BackwardStack.Count -ge 0 ) {
    for ( $i = 0; $i -lt $BackwardStack.Count; $i++ ) {
      $outputObject  = Out-Object `
        @{"Current"  = $null},
        @{"Id"       = $i},
        @{"Location" = $BackwardStack[$i]}
      $outputObject.PSObject.TypeNames.Insert(0, "System.Management.Automation.PSCustomObject.LocationHistoryObject")
      $outputObject
    }
  }
  $ndx = $BackwardStack.Count
  $outputObject = Out-Object `
    @{"Current"  = "=>"},
    @{"Id"       = $ndx},
    @{"Location" = $ExecutionContext.SessionState.Path.CurrentLocation.Path}
  $outputObject.PSObject.TypeNames.Insert(0, "System.Management.Automation.PSCustomObject.LocationHistoryObject")
  $outputObject
  if ( $ForwardStack.Count -ge 0 ) {
    $ndx++
    for ( $i = 0; $i -lt $ForwardStack.Count; $i++ ) {
      $outputObject = Out-Object `
        @{"Current"  = $null},
        @{"Id"       = $ndx + $i},
        @{"Location" = $ForwardStack[$i]}
      $outputObject.PSObject.TypeNames.Insert(0, "System.Management.Automation.PSCustomObject.LocationHistoryObject")
      $outputObject
    }
  }
}

# Removes a location from the location history.
function Remove-LocationHistory {
  <#
  .SYNOPSIS
  Removes a location from the location history.
 
  .DESCRIPTION
  Removes a location from the location history. This is useful when a location in the location history is no longer valid (e.g., a location that has been renamed or removed).
 
  .PARAMETER Id
  Removes the specified location from the location history.
 
  .EXAMPLE
  PS C:\> Remove-LocationHistory 3
  Removes location Id 3 from the location history.
  #>

  [CmdletBinding(DefaultParameterSetName="Path")]
  param(
    [Parameter(Mandatory=$true)]
      [Int] $Id
  )
  if ( ($Id -lt 0) -or ($id -gt ($MAX_HISTORY_SIZE - 1)) ) {
    Write-Warning ("Id must be between 0 and {0}." -f ($MAX_HISTORY_SIZE - 1))
    return
  }
  if ( $Id -eq $BackwardStack.Count ) {
    Write-Warning "Cannot remove the current location from the location history."
    return
  }
  if ( $Id -lt $BackwardStack.Count ) {
    $BackwardStack.RemoveAt($Id)
  }
  elseif ( ($Id -gt $BackwardStack.Count) -and ($Id -lt ($BackwardStack.Count + 1 + $ForwardStack.Count)) ) {
    $ndx = $Id - ($BackwardStack.Count + 1)
    $ForwardStack.RemoveAt($ndx)
  }
  else {
    Write-Warning ("{0} is not a location in the location history." -f $Id)
  }
}

# Set the location and update the location history
function Set-LocationEx {
  <#
  .SYNOPSIS
  Set-Location replacement that maintains a location history, allowing easy navigation to previous locations.
 
  .DESCRIPTION
  Set-Location replacement that maintains a location history, allowing easy navigation to previous locations. The location history contains the list of locations visited in the current PowerShell session. The location history stores up to 100 locations.
 
  .PARAMETER Path
  Specifies the path of a new working location. "." refers to the current location, ".." refers to the current location's parent, "..." to that location's parent, and so forth. If the location is a leaf element (such as the name of a file), the command will change to the location to the leaf element's container.
 
  .PARAMETER LiteralPath
  Specifies the path of a new working location. This parameter used exactly as it is typed.
 
  .PARAMETER Backward
  Changes to the previous location in the location history. You can shorten this parameter name to "-b".
 
  .PARAMETER Forward
  Changes to the next location in the location history. You can shorten this parameter name to "-f".
 
  .PARAMETER Id
  Changes to the specified location Id in the location history. You can omit or shorten this parameter name to "-i".
 
  .PARAMETER CopyToClipboard
  Copies the current or new location to the clipboard. You can shorten this parameter name to "-c".
 
  .PARAMETER PassThru
  If changing locations, this parameter causes Set-LocationEx to return a PathInfo object that represents the new location.
 
  .PARAMETER UseTransaction
  If changing locations, this parameter causes Set-LocationEx to include the command in the active transaction. This parameter is valid only when a transaction is in progress. For more information, see help about_Transactions.
 
  .EXAMPLE
  PS C:\> Set-LocationEx
  Outputs the location history (same as Get-LocationHistory).
 
  .EXAMPLE
  PS C:\> Set-LocationEx ...
  Changes two levels up from the current location. For example, if you are in C:\Windows\System32\WindowsPowerShell\v1.0, this command will change to C:\Windows\System32.
 
  .EXAMPLE
  PS C:\> Set-LocationEx 3
  Changes to location Id 3 in the location history. Use Set-LocationEx without parameters or Get-LocationHistory to see the location history. With only a location Id parameter, the -Id parameter name itself is optional.
 
  .EXAMPLE
  PS C:\> Set-LocationEx "10"
  Changes to the location named "10" in the current location. Without the quotes, Set-LocationEx will interpret the parameter as a location Id. You can also prefix the location with ".\" to prevent Set-LocationEx from interpreting the parameter as a location Id; e.g.: "Set-LocationEx .\10".
 
  .EXAMPLE
  PS C:\> Set-LocationEx -Backward
  Changes to the previous location in the location history.
 
  .EXAMPLE
  PS C:\> Set-LocationEx -Forward
  Changes to the next location in the location history.
 
  .EXAMPLE
  PS C:\> Set-LocationEx -Id 15 -CopyToClipboard
  Changes to location Id 15 and copies its path to the clipboard. The -CopyToClipboard parameter can also be specified as -Clipboard (or -Clip, or just -c).
 
  .EXAMPLE
  PS C:\> Set-LocationEx $PROFILE
  Changes to the parent location of the file named in the $PROFILE variable.
 
  .EXAMPLE
  PS C:\> Set-LocationEx -CopyToClipboard
  Copies the current location to the clipboard.
  #>

  [CmdletBinding(DefaultParameterSetName="Path")]
  param(
    [Parameter(Position=0,ParameterSetName="Path",ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
      [String] $Path,
    [Parameter(Position=0,ParameterSetName="LiteralPath",ValueFromPipelineByPropertyName=$true,Mandatory=$true)]
      [String] $LiteralPath,
    [Parameter(Position=0,ParameterSetName="Backward",Mandatory=$true)]
      [Switch] $Backward,
    [Parameter(Position=0,ParameterSetName="Forward",Mandatory=$true)]
      [Switch] $Forward,
    [Parameter(Position=0,ParameterSetName="Id",Mandatory=$true)]
      [Int] $Id,
      [Alias("Clipboard")] [Switch] $CopyToClipboard,
      [Switch] $PassThru,
      [Switch] $UseTransaction
  )
  begin {
    # Internal implementation that calls Set-Location
    function SetLocation {
      param(
        $path,
        [Switch] $literalPath
      )
      if ( ($PSCmdlet.ParameterSetName -eq "LiteralPath") -or $literalPath ) {
        Set-Location -LiteralPath $path -UseTransaction:$UseTransaction
      }
      else {
        Set-Location $path -UseTransaction:$UseTransaction
      }
      if ( $PassThru ) {
        Write-Output $ExecutionContext.SessionState.Path.CurrentLocation
      }
    }
  }
  process {
    $currentPathInfo = $ExecutionContext.SessionState.Path.CurrentLocation
    if ( $PSCmdlet.ParameterSetName -eq "Backward" ) {
      if ( $BackwardStack.Count -eq 0 ) {
        Write-Warning "No previous location in location history."
      }
      else {
        $lastNdx = $BackwardStack.Count - 1
        $prevPath = $BackwardStack[$lastNdx]
        SetLocation $prevPath -literalPath
        if ( $currentPathInfo.Path -ne $ExecutionContext.SessionState.Path.CurrentLocation.Path ) {
          [Void] $ForwardStack.Insert(0, $currentPathInfo.Path)
          $BackwardStack.RemoveAt($lastNdx)
          if ( $CopyToClipboard ) {
            [Windows.Forms.Clipboard]::SetText($ExecutionContext.SessionState.Path.CurrentLocation.Path)
          }
        }
      }
      return
    }
    if ( $PSCmdlet.ParameterSetName -eq "Forward" ) {
      if ( $ForwardStack.Count -eq 0 ) {
        Write-Warning "No next location in location history."
      }
      else {
        $nextPath = $ForwardStack[0]
        SetLocation $nextPath -literalPath
        if ( $currentPathInfo.Path -ne $ExecutionContext.SessionState.Path.CurrentLocation.Path ) {
          [Void] $BackwardStack.Add($currentPathInfo.Path)
          $ForwardStack.RemoveAt(0)
          if ( $CopyToClipboard ) {
            [Windows.Forms.Clipboard]::SetText($ExecutionContext.SessionState.Path.CurrentLocation.Path)
          }
        }
      }
      return
    }
    if ( $PSCmdlet.ParameterSetName -eq "Id" ) {
      if ( ($Id -lt 0) -or ($id -gt ($MAX_HISTORY_SIZE - 1)) ) {
        Write-Warning ("Id must be between 0 and {0}." -f ($MAX_HISTORY_SIZE - 1))
        return
      }
      if ( $Id -eq $BackwardStack.Count ) {
        return  # Going nowhere
      }
      if ( $Id -lt $BackwardStack.Count ) {
        $selectedPath = $BackwardStack[$Id]
        SetLocation $selectedPath -literalPath
        if ( $currentPathInfo.Path -ne $ExecutionContext.SessionState.Path.CurrentLocation.Path ) {
          [Void] $ForwardStack.Insert(0, $currentPathInfo.Path)
          $BackwardStack.RemoveAt($Id)
          $ndx = $Id
          $count = $BackwardStack.Count - $ndx
          if ( $count -gt 0 ) {
            $itemsToMove = $BackwardStack.GetRange($ndx, $count)
            $ForwardStack.InsertRange(0, $itemsToMove)
            $BackwardStack.RemoveRange($ndx, $count)
          }
          if ( $CopyToClipboard ) {
            [Windows.Forms.Clipboard]::SetText($ExecutionContext.SessionState.Path.CurrentLocation.Path)
          }
        }
      }
      elseif ( ($Id -gt $BackwardStack.Count) -and ($Id -lt ($BackwardStack.Count + 1 + $ForwardStack.Count)) ) {
        $ndx = $Id - ($BackwardStack.Count + 1)
        $selectedPath = $ForwardStack[$ndx]
        SetLocation $selectedPath -literalPath
        if ( $currentPathInfo.Path -ne $ExecutionContext.SessionState.Path.CurrentLocation.Path ) {
          [Void] $BackwardStack.Add($currentPathInfo.Path)
          $ForwardStack.RemoveAt($ndx)
          $count = $ndx
          if ( $count -gt 0 ) {
            $itemsToMove = $ForwardStack.GetRange(0, $count)
            $BackwardStack.InsertRange(($BackwardStack.Count), $itemsToMove)
            $ForwardStack.RemoveRange(0, $count)
          }
          if ( $CopyToClipboard ) {
            [Windows.Forms.Clipboard]::SetText($ExecutionContext.SessionState.Path.CurrentLocation.Path)
          }
        }
      }
      else {
        Write-Warning ("{0} is not a location in the location history." -f $Id)
      }
      return
    }
    if ( $PSCmdlet.ParameterSetName -eq "Path" ) {
      $newPath = $Path
    }
    else {
      $newPath = $LiteralPath
    }
    if ( -not $newPath ) {
      if ( -not $CopyToClipboard ) {
        Get-LocationHistory
      }
      else {
        [Windows.Forms.Clipboard]::SetText($ExecutionContext.SessionState.Path.CurrentLocation.Path)
      }
      return
    }
    # Expand ..[.]+ to ..\..[\..]+
    if ( $newPath -like "*...*" ) {
      $regex = [Regex] '\.\.\.'
      while ( $regex.IsMatch($newPath) ) {
        $newPath = $regex.Replace($newPath, "..\..")
      }
    }
    $driveName = ""
    if ( $ExecutionContext.SessionState.Path.IsPSAbsolute($newPath, [Ref] $driveName) -and
         (-not (Test-Path -LiteralPath $newPath -PathType Container)) ) {
      # File or a non-existent path
      $newPath = Split-Path $newPath -Parent
    }
    SetLocation $newPath
    if ( $currentPathInfo.Path -ne $ExecutionContext.SessionState.Path.CurrentLocation.Path ) {
      # Remove oldest entry if size exceeded
      if ( ($BackwardStack.Count + $ForwardStack.Count + 1) -eq $MAX_HISTORY_SIZE ) {
        $BackwardStack.RemoveAt(0)
      }
      [Void] $BackwardStack.Add($currentPathInfo.Path)
      # Append new locations to end of stack
      if ( $ForwardStack.Count -gt 0 ) {
        $BackwardStack.InsertRange($BackwardStack.Count, $ForwardStack)
        $ForwardStack.Clear()
      }
      if ( $CopyToClipboard ) {
        [Windows.Forms.Clipboard]::SetText($ExecutionContext.SessionState.Path.CurrentLocation.Path)
      }
    }
  }
}

$PreviousAlias = Get-Alias cd -ErrorAction SilentlyContinue

Set-Alias `
  -Name cd `
  -Value Set-LocationEx `
  -Description "Set-Location replacement that maintains a location history" `
  -Force `
  -Option AllScope `
  -Scope Global

$ExecutionContext.SessionState.Module.OnRemove = {
  if ( $PreviousAlias ) {
    Set-Alias `
    -Name cd `
    -Value $PreviousAlias.Definition `
    -Force `
    -Option $PreviousAlias.Options `
    -Scope Global
  }
}.GetNewClosure()