├── .gitignore ├── PsFzfLite.Tests ├── history.txt ├── PsFzfLiteBin.Tests.ps1 └── PsFzfLiteCore.Tests.ps1 ├── appveyor.yml ├── ci.ps1 ├── PsFzfLite ├── PsFzfLite.psd1 ├── PipelineHelper.cs ├── PsFzfLite.psm1 ├── PsFzfLiteFuzzies.ps1 ├── PsFzfLiteCore.ps1 ├── PsFzfLiteBin.ps1 └── FileSystemWalker.cs ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .vs*/ 2 | bin/ 3 | obj/ 4 | *.xml 5 | -------------------------------------------------------------------------------- /PsFzfLite.Tests/history.txt: -------------------------------------------------------------------------------- 1 | foo 2 | bar 3 | bar 4 | foo 5 | 6 | last 7 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | image: 2 | - Visual Studio 2017 3 | - Visual Studio 2019 4 | - Ubuntu 5 | 6 | install: 7 | - ps: | 8 | Install-Module -Name Pester -Force -SkipPublisherCheck 9 | 10 | build: 11 | off 12 | 13 | test_script: 14 | - pwsh: | 15 | . ./ci.ps1 16 | 17 | for: 18 | - 19 | matrix: 20 | only: 21 | - image: Visual Studio 2017 22 | test_script: 23 | - ps: | 24 | . ./ci.ps1 -------------------------------------------------------------------------------- /ci.ps1: -------------------------------------------------------------------------------- 1 | Write-Host ($PSVersionTable | Out-String) 2 | Import-Module Pester 3 | $config = [PesterConfiguration]::Default 4 | $config.Run.Path = $PSScriptRoot 5 | $config.Run.PassThru = $True 6 | $config.TestResult.Enabled = $True 7 | $res = Invoke-Pester -Configuration $config 8 | if ($env:APPVEYOR_JOB_ID) { 9 | [System.Net.WebClient]::new().UploadFile("https://ci.appveyor.com/api/testresults/nunit/$($env:APPVEYOR_JOB_ID)", 10 | (Resolve-Path $config.TestResult.OutputPath.Value)) 11 | } 12 | if ($res.FailedCount -gt 0) { 13 | throw "$($res.FailedCount) tests failed" 14 | } 15 | -------------------------------------------------------------------------------- /PsFzfLite/PsFzfLite.psd1: -------------------------------------------------------------------------------- 1 | @{ 2 | AliasesToExport = @() 3 | Author = 'Ignitron Engineering' 4 | CmdletsToExport = @('*') # Export-ModuleMember is used explicitly. 5 | CompanyName = 'Ignitron Engineering' 6 | CompatiblePSEditions = @('Desktop', 'Core') 7 | Copyright = '(c) Ignitron Engineering. All rights reserved.' 8 | Description = 'fzf utilities' 9 | FunctionsToExport = @('*') # Export-ModuleMember is used explicitly. 10 | GUID = '2b731225-c78c-4af1-994b-a19c89bac728' 11 | ModuleVersion = '1.0.0' 12 | PowerShellVersion = '5.1' 13 | RootModule = 'PsFzfLite.psm1' 14 | VariablesToExport = @() 15 | } 16 | -------------------------------------------------------------------------------- /PsFzfLite/PipelineHelper.cs: -------------------------------------------------------------------------------- 1 | #if !VS 2 | using System; 3 | using System.Management.Automation; 4 | 5 | namespace PsFzfLite 6 | { 7 | public static class PipelineHelper 8 | { 9 | private static readonly Type stopException = typeof( Cmdlet ).Assembly.GetType( 10 | "System.Management.Automation.StopUpstreamCommandsException" ); 11 | 12 | //https://stackoverflow.com/questions/1499466/powershell-equivalent-of-linq-any/34800670#34800670 13 | public static void StopUpstreamCommands( Cmdlet cmdlet ) 14 | { 15 | throw (Exception) System.Activator.CreateInstance( stopException, cmdlet ); 16 | } 17 | } 18 | } 19 | #endif 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Ignitron Engineering 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PsFzfLite/PsFzfLite.psm1: -------------------------------------------------------------------------------- 1 | # Arguments control what gets loaded and exported. 2 | # Usage: Import-Module PsfzfLite -ArgumentList @{Binaries = $True; Fuzzies = $True} 3 | # When omitting a value it gets assigned a default so it's not necessary to specify everything. 4 | param([parameter(Position = 0, Mandatory = $false)] [Hashtable] $ModuleArguments = @{}) 5 | 6 | $defaultArguments = @{ 7 | Binaries = $True; # Use PsFzfLiteBin, compile and use the .cs code. 8 | Fuzzies = $True; # Use PsFzfLiteFuzzies. 9 | } 10 | foreach ($item in $defaultArguments.GetEnumerator()) { 11 | if (-not $ModuleArguments.ContainsKey($item.Name)) { 12 | $ModuleArguments[$item.Name] = $item.Value 13 | } 14 | } 15 | 16 | Set-StrictMode -Version Latest 17 | 18 | if (-not (Test-Path variable:global:IsWindows)) { 19 | $platform = [System.Environment]::OSVersion.Platform 20 | $platformIsWindows = $platform -eq 'Win32NT' 21 | New-Variable -Option Constant -Name IsWindows -Value $platformIsWindows -Scope global 22 | } 23 | 24 | . (Join-Path $PSScriptRoot 'PsFzfLiteCore.ps1') 25 | 26 | Export-ModuleMember -Function @( 27 | 'Add-SingleQuotes', 28 | 'Remove-Quotes', 29 | 'Test-CommandIsCd', 30 | 'ConvertTo-ReplInput', 31 | 'ConvertTo-ShellCommand', 32 | 'Get-UniqueReversedLines', 33 | 'Get-ReadlineState', 34 | 'Get-PathBeforeCursor', 35 | 'Add-TextAtCursor', 36 | 'Get-InitialFzfQuery', 37 | 'Write-FzfResult' 38 | ) 39 | 40 | if ($ModuleArguments.Binaries) { 41 | . (Join-Path $PSScriptRoot 'PsFzfLiteBin.ps1') 42 | Install-FileSystemWalker 43 | Install-PipelineHelper 44 | Export-ModuleMember -Function @( 45 | 'Read-PipeOrTerminate', 46 | 'New-PipeOrTerminateArgs', 47 | 'New-InterruptibleCommand' 48 | ) 49 | Export-ModuleMember -Cmdlet @( 50 | 'Get-ChildPathNames' 51 | ) 52 | } 53 | 54 | if ($ModuleArguments.Fuzzies) { 55 | . (Join-Path $PSScriptRoot 'PsFzfLiteFuzzies.ps1') 56 | Export-ModuleMember -Function @( 57 | 'Invoke-FuzzyKillProcess', 58 | 'Invoke-FuzzyZLocation', 59 | 'Invoke-FuzzyGetCommand', 60 | 'Invoke-FuzzyGetCmdlet', 61 | 'Invoke-FuzzyHistory' 62 | ) 63 | if ($ModuleArguments.Binaries) { 64 | Export-ModuleMember -Function @( 65 | 'Invoke-FuzzyBrowse' 66 | ) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /PsFzfLite.Tests/PsFzfLiteBin.Tests.ps1: -------------------------------------------------------------------------------- 1 | Import-Module Pester 2 | Import-Module $PSScriptRoot.Replace('.Tests', '') -Force 3 | 4 | if (($PSVersionTable.PSVersion.Major -ge 6) -and $IsWindows) { 5 | Describe 'Read-PipeOrTerminate' { 6 | It 'Returns command output immediately after command exits' { 7 | & {while ($True) { 8 | 'foo' 9 | }} | 10 | cmd /d /c (New-PipeOrTerminateArgs 'echo abc') | 11 | Read-PipeOrTerminate | 12 | Should -Be 'abc' 13 | } 14 | 15 | It 'Stops pipeline immediately after external command exits without output' { 16 | & {while ($True) { 17 | 'foo' 18 | }} | 19 | cmd /d /c (New-PipeOrTerminateArgs 'oops 2>NUL') | 20 | Read-PipeOrTerminate | 21 | Should -Be $null 22 | } 23 | } 24 | } 25 | 26 | if ($PSVersionTable.PSVersion.Major -ge 6) { 27 | Describe 'New-InterruptibleCommand' { 28 | It 'Fails for non-existing commands' { 29 | {New-InterruptibleCommand nosuchcommandexists} | 30 | Should -Throw 31 | } 32 | 33 | if ($IsWindows) { 34 | It 'Pipes input to the command and stops when pipe exits' { 35 | 1..3 | 36 | New-InterruptibleCommand sort.exe | 37 | Should -Be @(1, 2, 3) 38 | } 39 | 40 | # This one should normally have exited already before we could get the handle, 41 | # which will print a warning. 42 | It 'Returns command output immediately after command exits' { 43 | & {while ($True) { 44 | 'foo' 45 | }} | 46 | New-InterruptibleCommand cmd /c echo abc | 47 | Should -Be 'abc' 48 | } 49 | 50 | # Whereas this one takes long enough that the normal exit event principle gets used. 51 | It 'Detects process exit for long-running commands' { 52 | New-InterruptibleCommand pwsh -NoProfile -Command 'Start-Sleep -Milli 500' | 53 | Should -Be $null 54 | } 55 | } 56 | else { 57 | It 'Returns command output immediately after command exits' { 58 | & {while ($True) { 59 | 'foo' 60 | }} | 61 | New-InterruptibleCommand cat nonexistingfile | 62 | Should -Be $null 63 | } 64 | 65 | It 'Pipes input to the command and stops pipe when command exits' { 66 | 1..3 | 67 | New-InterruptibleCommand head -n 3 | 68 | Should -Be @(1, 2, 3) 69 | } 70 | } 71 | } 72 | } 73 | 74 | Describe 'Get-ChildPathNames' { 75 | It 'Lists paths as strings relative to a directory' { 76 | $root = $PSScriptRoot 77 | $files = Get-ChildItem $root -File | ForEach-Object {$_.Name} 78 | Get-ChildPathNames -Path $root | Should -Be $files 79 | Get-ChildPathNames -Path "$root/somedir/.." | Should -Be $files 80 | Get-ChildPathNames -Path $root -PathReplacement './' | Should -Be ($files | ForEach-Object {"./$_"}) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /PsFzfLite.Tests/PsFzfLiteCore.Tests.ps1: -------------------------------------------------------------------------------- 1 | Import-Module Pester 2 | Import-Module $PSScriptRoot.Replace('.Tests', '') -Force 3 | 4 | Describe 'Add-SingleQuotes' { 5 | It 'Returns empty string if input null or empty' { 6 | Add-SingleQuotes | Should -Be '' 7 | Add-SingleQuotes '' | Should -Be '' 8 | } 9 | 10 | It 'Surrounds its arguments with single quotes' { 11 | Add-SingleQuotes 1 | Should -Be "'1'" 12 | Add-SingleQuotes 'foo' | Should -Be "'foo'" 13 | Add-SingleQuotes @('foo', 'bar') | Should -Be @("'foo'", "'bar'") 14 | @(1, 2) | Add-SingleQuotes | Should -Be @("'1'", "'2'") 15 | } 16 | 17 | It 'Escapes single quotes' { 18 | Add-SingleQuotes "a'b" | Should -Be "'a''b'" 19 | } 20 | } 21 | 22 | Describe 'Remove-Quotes' { 23 | It 'Ignores $null' { 24 | Remove-Quotes $null | Should -Be $null 25 | } 26 | 27 | It 'Trims all quotes from its argument' { 28 | Remove-Quotes '' | Should -Be '' 29 | Remove-Quotes "'''`"`"" | Should -Be '' 30 | Remove-Quotes "'a'`"b`"" | Should -Be "a'`"b" 31 | Remove-Quotes @('"a"', '"b"') | Should -Be @('a', 'b') 32 | } 33 | 34 | It 'Trims all quotes from each argument' { 35 | Remove-Quotes @('"a"', '"b"') | Should -Be @('a', 'b') 36 | } 37 | } 38 | 39 | Describe 'Test-CommandIsCd' { 40 | It 'Is true for cd-like arguments' { 41 | Test-CommandIsCd 'cd' | Should -BeTrue 42 | Test-CommandIsCd 'chdir' | Should -BeTrue 43 | Test-CommandIsCd 'pushd' | Should -BeTrue 44 | Test-CommandIsCd 'Set-Location' | Should -BeTrue 45 | } 46 | 47 | It 'Is false for everything else' { 48 | Test-CommandIsCd | Should -BeFalse 49 | Test-CommandIsCd 'notcd' | Should -BeFalse 50 | } 51 | } 52 | 53 | Describe 'ConvertTo-ReplInput' { 54 | It 'Does nothing if no input' { 55 | ConvertTo-ReplInput | Should -Be $null 56 | @() | ConvertTo-ReplInput | Should -Be $null 57 | } 58 | 59 | It 'Returns single argument as-is if possible' { 60 | 'foo' | ConvertTo-ReplInput | Should -Be 'foo' 61 | @('1') | ConvertTo-ReplInput | Should -Be '1' 62 | } 63 | 64 | It 'Makes quoted argument if needed' { 65 | 'f oo' | ConvertTo-ReplInput | Should -Be "'f oo'" 66 | "f`too" | ConvertTo-ReplInput | Should -Be "'f`too'" 67 | 'f`oo' | ConvertTo-ReplInput | Should -Be "'f``oo'" 68 | 'f|oo' | ConvertTo-ReplInput | Should -Be "'f|oo'" 69 | @('f oo') | ConvertTo-ReplInput | Should -Be "'f oo'" 70 | ConvertTo-ReplInput 1 | Should -Be '1' 71 | } 72 | 73 | It 'Makes REPL array representation of arguments' { 74 | 'f oo', 'bar' | ConvertTo-ReplInput | Should -Be "@('f oo','bar')" 75 | ConvertTo-ReplInput '1', '2' | Should -Be "@('1','2')" 76 | } 77 | } 78 | 79 | Describe 'ConvertTo-ShellCommand' { 80 | It 'Creates string with quoted arguments' { 81 | ConvertTo-ShellCommand @('a', '"b"') | Should -Be '"a" ""b""' 82 | } 83 | } 84 | 85 | Describe 'Get-UniqueReversedLines' { 86 | It 'Returns file content in MRU order' { 87 | Get-UniqueReversedLines (Join-Path $PSScriptRoot 'history.txt') | 88 | Should -Be @('last', 'foo', 'bar') 89 | } 90 | } 91 | 92 | Describe 'Get-PathBeforeCursor' { 93 | It 'Returns null if no input' { 94 | $tok = @( 95 | @{ 96 | Extent = @{EndOffset = 0} 97 | Text = '' 98 | } 99 | ) 100 | Get-PathBeforeCursor $tok 0 | Should -Be $null 101 | } 102 | 103 | It 'Detects single cd when cursor is after it' { 104 | $tok = @( 105 | @{ 106 | Extent = @{EndOffset = 3} 107 | Text = 'foo' 108 | }, 109 | @{ 110 | Extent = @{EndOffset = 6} 111 | Text = 'cd' 112 | } 113 | ) 114 | Get-PathBeforeCursor $tok 0 | Should -Be $null 115 | Get-PathBeforeCursor $tok 4 | Should -Be $null 116 | Get-PathBeforeCursor $tok 6 | Should -Be $null, $True 117 | } 118 | 119 | It 'Detects real path' { 120 | $pathToTest = $PSScriptRoot 121 | $tok = @( 122 | @{ 123 | Extent = @{EndOffset = $pathToTest.Length} 124 | Text = $pathToTest 125 | } 126 | ) 127 | Get-PathBeforeCursor $tok $pathToTest.Length | Should -Be $tok[0], $False 128 | } 129 | 130 | It 'Detects cd to real path' { 131 | $pathToTest = $PSScriptRoot 132 | $tok = @( 133 | @{ 134 | Extent = @{EndOffset = 2} 135 | Text = 'cd' 136 | }, 137 | @{ 138 | Extent = @{EndOffset = 3 + $pathToTest.Length} 139 | Text = $pathToTest 140 | } 141 | ) 142 | Get-PathBeforeCursor $tok (3 + $pathToTest.Length) | Should -Be $tok[1], $True 143 | } 144 | } 145 | 146 | -------------------------------------------------------------------------------- /PsFzfLite/PsFzfLiteFuzzies.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Pipe Get-Process into fzf, then into Stop-Process. 4 | #> 5 | function Invoke-FuzzyKillProcess { 6 | Get-Process | 7 | ForEach-Object {$_.Name + ' ' + $_.Id} | 8 | fzf --multi | 9 | ForEach-Object {$_.Split(' ')[-1]} | 10 | ForEach-Object {Stop-Process $_ -Confirm} 11 | } 12 | 13 | <# 14 | .SYNOPSIS 15 | Pipe Get-ZLocation into fzf, then into Set-Location. 16 | .DESCRIPTION 17 | ZLocation is great, the combination with fzf is even better: 18 | never manually cd more than once anymore. 19 | #> 20 | function Invoke-FuzzyZLocation { 21 | (Get-ZLocation).GetEnumerator() | 22 | Sort-Object {$_.Value} -Desc | 23 | ForEach-Object {$_.Name.ToLower()} | # Get-ZLocation can have duplicate locations with mixed case. 24 | Select-Object -Unique | 25 | fzf | 26 | Select-Object -First 1 | 27 | Set-Location 28 | } 29 | 30 | <# 31 | .SYNOPSIS 32 | Pipe Get-Command for non-PS types into fzf and insert result at cursor. 33 | .DESCRIPTION 34 | Use for fuzzy browsing any .exe in the PATH. 35 | If cursor is at the end of a word, that word is used as initial fzf query and replaced afterwards. 36 | .PARAMETER FzfArgs 37 | Additional arguments for fzf. 38 | .EXAMPLE 39 | Invoke-FuzzyGetCommand -FzfArgs @('--preview', '(Get-Command {}).Source') 40 | #> 41 | function Invoke-FuzzyGetCommand { 42 | param( 43 | [Parameter()] [Object[]] $FzfArgs = @() 44 | ) 45 | $FzfArgs, $fzfQuery = Get-InitialFzfQuery $FzfArgs 46 | Get-Command -CommandType Application | 47 | ForEach-Object {$_.Name} | 48 | fzf @FzfArgs | 49 | Write-FzfResult -FzfQuery $fzfQuery 50 | } 51 | 52 | <# 53 | .SYNOPSIS 54 | Pipe Get-Command for PS types into fzf and insert result at cursor. 55 | .DESCRIPTION 56 | Use for fuzzy browsing PS commands. 57 | If cursor is at the end of a word, that word is used as initial fzf query and replaced afterwards. 58 | .PARAMETER FzfArgs 59 | Additional arguments for fzf. 60 | .EXAMPLE 61 | Invoke-FuzzyGetCmdlet -FzfArgs @('--preview', 'Get-Command {} -ShowCommandInfo') 62 | #> 63 | function Invoke-FuzzyGetCmdlet { 64 | param( 65 | [Parameter()] [Object[]] $FzfArgs = @() 66 | ) 67 | $FzfArgs, $fzfQuery = Get-InitialFzfQuery $FzfArgs 68 | @( 69 | (Get-Command -CommandType Function -ListImported), 70 | (Get-Command -CommandType CmdLet -ListImported), 71 | (Get-Command -CommandType Alias -ListImported) 72 | ) | 73 | ForEach-Object {$_.Name} | 74 | fzf @FzfArgs | 75 | Write-FzfResult -FzfQuery $fzfQuery 76 | } 77 | 78 | <# 79 | .SYNOPSIS 80 | Pipe PSReadLine history into fzf and insert result at cursor. 81 | .DESCRIPTION 82 | The typical Ctrl-R command. 83 | If cursor is at the end of a word, that word is used as initial fzf query and replaced afterwards. 84 | .PARAMETER FzfArgs 85 | Additional arguments for fzf. 86 | .EXAMPLE 87 | # Add a key binding for deleting selected entries from history. 88 | Invoke-FuzzyGetCmdlet -FzfArgs @( 89 | '--multi', # Not too useful when selecting history, but extremely useful for deleting multiple entries. 90 | '--bind', 91 | 'ctrl-d:execute($i=@(Get-Content {+f}); $h=(Get-PSReadLineOption).HistorySavePath; (Get-Content $h) | ?{$_ -notin $i} | Out-File $h -Encoding utf8NoBom)' 92 | ) 93 | #> 94 | function Invoke-FuzzyHistory { 95 | param( 96 | [Parameter()] [Object[]] $FzfArgs = @() 97 | ) 98 | $FzfArgs += '--no-sort' # Get-UniqueReversedLines already has proper order. 99 | $FzfArgs, $fzfQuery = Get-InitialFzfQuery $FzfArgs 100 | Get-UniqueReversedLines (Get-PSReadLineOption).HistorySavePath | 101 | fzf @fzfArgs | 102 | Write-FzfResult -FzfQuery $fzfQuery 103 | } 104 | 105 | <# 106 | .SYNOPSIS 107 | List files or directories and pipe into fzf, inserting the result at the cursor. 108 | .DESCRIPTION 109 | The typical Ctrl-P (or Ctrl-T depending on what you're used to) command. 110 | Will try to figure out what is meant: looks for the start path currently before the cursor 111 | and when there's a 'cd' will only browse directories. Unlike standard implementations 112 | this also means it's possible to browse for directories outside of the current one by 113 | first typing the path and then invoking this function. 114 | .PARAMETER FzfDirArgs 115 | Additional arguments for fzf when browsing directories. 116 | .PARAMETER FzfFileArgs 117 | Additional arguments for fzf when browsing files. 118 | .PARAMETER Directory 119 | Force listing directories, not files. 120 | .PARAMETER UseInterruptibleCommand 121 | Windows only: use New-InterruptibleCommand instead of Read-PipeOrTerminate. 122 | .OUTPUTS 123 | The fzf return value(s), quoted and ar array when needed. 124 | #> 125 | function Invoke-FuzzyBrowse { 126 | param( 127 | [Parameter()] [String[]] $FzfDirArgs = @(), 128 | [Parameter()] [String[]] $FzfFileArgs = @(), 129 | [Parameter()] [switch] $Directory, 130 | [Parameter()] [switch] $UseInterruptibleCommand 131 | ) 132 | $tokens, $cursor = Get-ReadlineState -Tokens 133 | $initPath, $isCd = Get-PathBeforeCursor $tokens $cursor 134 | if ($Directory) { 135 | $isCd = $True 136 | } 137 | if (-not $initPath) { 138 | $root = '.' 139 | $pathReplacement = '' 140 | } 141 | else { 142 | $root = Remove-Quotes $initPath.Text 143 | $pathReplacement = $root 144 | } 145 | if ($isCd) { 146 | if ($UseInterruptibleCommand -or -not $IsWindows) { 147 | $result = Get-ChildPathNames -Path $root -PathReplacement $pathReplacement -SearchType ( 148 | [PsFzfLite.FileSystemWalker+SearchType]::Directories) | 149 | New-InterruptibleCommand (@('fzf') + $FzfDirArgs) 150 | } 151 | else { 152 | # Note: even though Get-ChildPathNames is plenty fast, we unfortunately still have to pipe 153 | # the output around so don't achieve the typical raw fzf speed here but around 3x slower. 154 | # Still much better than using cmd /c dir or Get-ChildItem though. 155 | # Note: see https://stackoverflow.com/a/64666821/128384 on why this is useless for pre-v6 PS versions: 156 | # they buffer the complete pipe before passing anything to an external program. 157 | # The /d is to skip autoruns, shouldn't be needed here. 158 | $result = Get-ChildPathNames -Path $root -PathReplacement $pathReplacement -SearchType ( 159 | [PsFzfLite.FileSystemWalker+SearchType]::Directories) | 160 | cmd /d /c (New-PipeOrTerminateArgs (ConvertTo-ShellCommand (@('fzf') + $FzfDirArgs))) | 161 | Read-PipeOrTerminate 162 | } 163 | } 164 | elseif (-not $initPath) { 165 | # The fast path: browse files from current dir. 166 | $result = fzf @FzfFileArgs 167 | } 168 | else { 169 | if ($UseInterruptibleCommand -or -not $IsWindows) { 170 | $result = Get-ChildPathNames -Path $root -PathReplacement $pathReplacement -SearchType ( 171 | [PsFzfLite.FileSystemWalker+SearchType]::Files) | 172 | New-InterruptibleCommand (@('fzf') + $FzfFileArgs) 173 | } 174 | else { 175 | $result = Get-ChildPathNames -Path $root -PathReplacement $pathReplacement -SearchType ( 176 | [PsFzfLite.FileSystemWalker+SearchType]::Files) | 177 | cmd /d /c (New-PipeOrTerminateArgs (ConvertTo-ShellCommand (@('fzf') + $FzfFileArgs))) | 178 | Read-PipeOrTerminate 179 | } 180 | } 181 | # We'll paste a path containg the current path already so delete the current one first. 182 | if ($initPath -and $result) { 183 | $len = $initPath.Extent.EndOffset - $initPath.Extent.StartOffset 184 | [Microsoft.PowerShell.PSConsoleReadLine]::Replace($initPath.Extent.StartOffset, $len, '') 185 | } 186 | $result | ConvertTo-ReplInput | Add-TextAtCursor 187 | } 188 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![AppVeyor tests](https://img.shields.io/appveyor/tests/stinos/PsFzfLite?logo=appveyor) 2 | 3 | # PsFzfLite 4 | 5 | PsFzfLite is a PowerShell module providing helpers for working with [fzf](https://github.com/junegunn/fzf), 6 | which is a commandline fuzzy finder. The most common day-to-day usage functions are provided as well: 7 | fuzzy file and directory finding (ctrl-t and alt-c from fzf), fuzzy history (ctrl-r). 8 | 9 | It was inspired by [PSFzf](https://github.com/kelleyma49/PSFzf) but written from scratch taking a different 10 | approach with the aim of providing a couple of building blocks one can use to make their own custom 11 | functions using fzf, instead of being a complete wrapper; see rationale below. 12 | 13 | As such [PsFzfLiteCore.ps1](PsFzfLite/PsFzfLiteCore.ps1) just has a few functions which are typically used 14 | to provide input for and handle output from fzf. These can then be used to build higher-level functions 15 | using. By means of example and documentation, that's exactly what is done for the most common functions, 16 | see [PsFzfLiteFuzzies.ps1](PsFzfLite/PsFzfLiteFuzzies.ps1). 17 | 18 | ## Requirements 19 | 20 | Powershell Core. 21 | 22 | Notes: 23 | - actually code is compatible with PS 5.1 but that version buffers all output from an upstream command before 24 | piping it into a downstream external program so without workarounds (which aren't implemented currently), 25 | feeding lots of output (like from listing the filesystem) into fzf takes a considerable amount of time 26 | - all tests pass on Linux and all fuzzies have been tested as well, but not extensively. Not tested on macOS. 27 | 28 | ## Installation 29 | 30 | ```powershell 31 | git clone https://github.com/stinos/PsFzfLite ./PsFzfLite 32 | Import-Module ./PsFzfLite/PsFzfLite/PsFzfLite.psm1 33 | ``` 34 | 35 | The import statement normally goes into `$PROFILE`. 36 | 37 | Note that on the first import, or when the .cs files change after pulling updates, this will build 2 38 | assemblies into the temp directory. Done mainly so to avoid having to go through building and 39 | deploying binary releases. 40 | 41 | The module supports arguments to control what gets imported, see [PsFzfLite.psm1](PsFzfLite/PsFzfLite.psm1). 42 | 43 | ## Functions for everyday usage 44 | 45 | - `Invoke-FuzzyGetCommand` launch fzf with list of all .exe files in $env:PATH and insert selection at cursor, 46 | covenient for quickly finding commands 47 | - `Invoke-FuzzyGetCmdlet` like above, but for Powershell commands 48 | - `Invoke-FuzzyHistory` feed PsReadLine history into fzf (no duplicates, MRU order) and insert selection 49 | - `Invoke-FuzzyKillProcess` launch fzf with Get-Process list and kill selected processes 50 | - `Invoke-FuzzyBrowse` select from list of paths and insert selection; uses current token before cursor 51 | as start path and automatically selects directory finding if there's a `cd` or similar on the commandline 52 | - `Invoke-FuzzyZLocation` launch fzf with [ZLocation](https://github.com/vors/ZLocation) entries and 53 | cd to the selected directory, use for fast navigation between directories used often 54 | 55 | The easiest way to use these is binding them to keyboard shortcuts so they can be invoked quickly, plus 56 | first 3 will automatically pre-fill fzf input with the current commandline content. See samples below. 57 | 58 | Most of the functions have an argument for passing arguments to fzf in turn; these arguments are 59 | passed like one would type them on the commandline. Some examples: 60 | 61 | ```powershell 62 | # All the usual fzf arguments can be passed. 63 | $fzfArgs = @( 64 | '--multi', 65 | '--preview', 'type {}', 66 | '--preview-window="right:60%"', 67 | '--bind', 'backward-eof:abort,ctrl-s:clear-selection' 68 | ) 69 | Invoke-FuzzyBrowse -FzfFileArgs $fzfArgs 70 | 71 | # Preview with command info, requires recent fzf version which adheres to $env:SHELL='pwsh' 72 | # or similar, so it can run preview/execute via PS. 73 | Invoke-FuzzyGetCmdlet -FzfArgs @('--preview', 'Get-Command {} -ShowCommandInfo') 74 | 75 | # Fuzzy history with option to delete selected entries. 76 | Invoke-FuzzyGetCmdlet -FzfArgs @( 77 | '--multi', # Not useful when selecting history, but very useful for deleting multiple entries. 78 | '--bind', 79 | 'ctrl-d:execute($i=@(Get-Content {+f}); $h=(Get-PSReadLineOption).HistorySavePath; (Get-Content $h) | ?{$_ -notin $i} | Out-File $h -Encoding utf8NoBom)' 80 | ) 81 | ``` 82 | 83 | ## Sample key bindings and aliases 84 | 85 | Key bindings like the ones fzf installs by default in other shells like bash, put in `$PROFILE`: 86 | 87 | ```powershell 88 | Set-PSReadLineKeyHandler -Key 'ctrl-r' -BriefDescription 'Fuzzy history' -ScriptBlock {Invoke-FuzzyHistory} 89 | Set-PSReadLineKeyHandler -Key 'ctrl-t' -BriefDescription 'Fuzzy browse' -ScriptBlock {Invoke-FuzzyBrowse} 90 | Set-PSReadLineKeyHandler -Key 'alt-c' -BriefDescription 'Fuzzy browse dirs' -ScriptBlock {Invoke-FuzzyBrowse -Directory} 91 | ``` 92 | 93 | Aliases like this can also be useful: 94 | ```powershell 95 | Set-Alias fz Invoke-FuzzZLocation 96 | Set-Alias fkill Invoke-FuzzyKillProcess 97 | ``` 98 | 99 | An alternative approach to aliasing is using `Invoke-FuzzyGetCmdlet` bound to a keyboard shortcut then use 100 | that for fuzzy command finding instead of typing aliases: it is usually about the same number of keystrokes to 101 | reach a command but doesn't require remembering the exact name and is generic. For example starting 102 | `Invoke-FuzzyGetCmdlet` and typing `fz` or `fk` (or the other way around, i.e. typing `fz` or `fk` and then 103 | using the keyboard shortcut) fuzzy matches the functions shown above. 104 | 105 | ## Rationale and helper functions 106 | 107 | For a lot of things fzf works fine as-is in Powershell. For example one can use 108 | 109 | ```powershell 110 | $selectedLines = Get-Content foo.txt | fzf --multi --no-sort 111 | ``` 112 | 113 | without needing any extra functions (so even no PsFzfLite :]), let alone wrapping. Especially when knowing 114 | fzf already and/or using it in other shells it's convenient to be able to just use the same in Powershell 115 | (as opposed to having to figure out the corresponding arguments for PSFzf for instance). 116 | 117 | Moreover there are a lot of different usecases for fzf and it has a lot of options, so the wrapping/do-it-all 118 | approach is on one hand never enough in that people can continue to ask new features to satisfy their own 119 | customizations while on the other hand it's always overkill in that the majority of the code is not used by 120 | other people because they do things in a different way. So an approach where one uses fzf directly to write 121 | a couple of often-used functions has its merits in that it is fast and does exactly what's needed 122 | but nothing more. 123 | 124 | Using fzf like that in Powershell however does run into few issues, and solutions for these are 125 | what PsFzfLite is about: 126 | - when binding functions using fzf to a keyboard shortcut, the selected result usually needs to be 127 | inserted at the current cursor position. That's fairly easy using PsReadLine and PsFzfLite has 128 | the basics to do that in a pipe: `fzf | Add-TextAtCursor`. 129 | - likewise the output of fzf might need to be quoted or wrapped in an array, for example if fzf returns 2 130 | lines 'foo' and 'bar' that should become `@('foo', 'bar')`: `fzf | ConvertTo-ReplInput | Add-TextAtCursor`. 131 | - fuzzy file finding using `Get-ChildItem | fzf` or `cmd /c dir | fzf` is so slow it's hardly usable for 132 | more than a couple of directories deep. PsFzfLite implements its own filesystem walking in C# which performs 133 | similar to what fzf uses internally (see [FileSystemWalker.cs](PsFzfLite/FileSystemWalker.cs). 134 | There is still some overhead for using it in Powershell and piping into fzf and as such it's not as fast as 135 | using bare fzf, but still like 10 times faster than PSFzf and also more consistent with standard fzf: it uses 136 | no full paths but paths relative to the directory and filters out dot directories. 137 | Ballpark performance numbers as seen from within PS Measure-Command {...} for a directory 138 | with roughly 0.7 million files, no filtering, after some warmup, numbers in seconds: 139 | 140 | - Get-ChildPathNames (Windows version): 3.9 141 | - Get-ChildPathNames (portable version): 4.8 142 | - fd -H -I: 8.1 143 | - go executable using most basic sample from github.com/saracen/walker (which is what fzf uses internally): 8.6 144 | - cmd /c dir /b /s /a-d: 31.5 145 | - Get-ChildItem -Path -File -Recurse: 36.5 146 | 147 | Same principle but on PS Core in WSL on a directory with about 200000 files: 148 | - fd -H -I: 10 149 | - Get-ChildPathNames (portable version): 15 150 | - find -type f: 50 151 | - Get-ChildItem -Path -File -Recurse: 95 152 | - at the time of writing there is no builtin way to stop a pipeline, nor do upstream elements detect when a 153 | downstream element stopped, so piping a lot of input into fzf is problematic since the pipe will continue even 154 | after making a selection in fzf rendering it useless. None of the workarounds are super pretty. 155 | PsFzfLite has 2 of them: 156 | - Windows-only: `TonsOfInput | cmd /c (New-PipeOrTerminateArgs 'fzf') | Read-PipeOrTerminate` 157 | - portable: `TonsOfInput | New-InterruptibleCommand fzf` 158 | 159 | Currently these are used only in `Invoke-FuzzyBrowse`, none of the other examples produce enough input that 160 | waiting for the input to enter fzf takes much longer than deciding + typing the fuzzy string. 161 | -------------------------------------------------------------------------------- /PsFzfLite/PsFzfLiteCore.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Surround string with single quotes. 4 | .PARAMETER Items 5 | String(s) to quote. 6 | .OUTPUTS 7 | Quoted strings(s). 8 | #> 9 | function Add-SingleQuotes { 10 | param ( 11 | [Parameter(ValueFromPipeline)] [String[]] $Items 12 | ) 13 | process { 14 | if ($null -eq $Items) { 15 | '' 16 | } 17 | foreach ($item in $Items) { 18 | if (-not $item) { 19 | '' 20 | } 21 | else { 22 | $esc = [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($item) 23 | "'$esc'" 24 | } 25 | } 26 | } 27 | } 28 | 29 | <# 30 | .SYNOPSIS 31 | Trim all quotes. 32 | .PARAMETER Value 33 | String(s) to trim. 34 | .OUTPUTS 35 | Trimmed string. 36 | #> 37 | function Remove-Quotes { 38 | param ( 39 | [Parameter()] [String[]] $Value 40 | ) 41 | if ($null -ne $Value) { 42 | $Value.Trim('''"') 43 | } 44 | } 45 | 46 | <# 47 | .SYNOPSIS 48 | Check if a command is cd-like (Set-Location, Push-Location and aliases). 49 | #> 50 | function Test-CommandIsCd { 51 | param ( 52 | [Parameter()] [String] $Name 53 | ) 54 | if (-not $Name) { 55 | return $False 56 | } 57 | $command = Get-Command $Name -Type Alias -ErrorAction SilentlyContinue 58 | if (-not $command) { 59 | # Not an alias 60 | $command = $Name 61 | } 62 | elseif ($command | Get-Member 'ResolvedCommandName') { 63 | $command = $command.ResolvedCommandName 64 | } 65 | return $command -in @('Push-Location', 'Set-Location') 66 | } 67 | 68 | <# 69 | .SYNOPSIS 70 | Turns input into string usable as a PS value when inserted on the commandline. 71 | .DESCRIPTION 72 | For use on fzf output: add quotes if needed (if it has a space or other special characters) 73 | and in case there are multiple items create an array @() representation. For instance when 74 | the input is @('foo', 'bar') this will return the string "@('foo', 'bar')" so when that gets 75 | pasted verbatim via PsReadLine functions it will turn up as @('foo', 'bar') again. 76 | .OUTPUTS 77 | A string, to be used with e.g. Add-TextAtCursor. 78 | #> 79 | function ConvertTo-ReplInput { 80 | param ( 81 | [Parameter(ValueFromPipeline)] [String[]] $Items 82 | ) 83 | begin { 84 | $allInput = @() 85 | } 86 | process { 87 | $allInput += $Items 88 | } 89 | end { 90 | if ($allInput.Length -gt 1) { 91 | "@($(($allInput | Add-SingleQuotes ) -Join ','))" 92 | } 93 | elseif ($allInput) { 94 | $allInput = $allInput[0] 95 | if ($allInput.IndexOfAny("``&@'#{}()$,;|<> `t") -ge 0) { 96 | Add-SingleQuotes $allInput 97 | } 98 | else { 99 | $allInput 100 | } 101 | } 102 | } 103 | } 104 | 105 | <# 106 | .SYNOPSIS 107 | Wrap each item passed in double quotes and return as one string joined by spaces. 108 | .DESCRIPTION 109 | For creating a command to use with cmd. Note this just quotes the arguments, so cmd 110 | recognizes them as individual arguments, but does not do any escaping: idea is to pass 111 | arguments as one would type them on the commandline. So when typing this in PS: 112 | fzf --preview-window="right:60%" --preview 'echo \"quo\"' 113 | this is equivalent to runnig this (so with the same arguments) with cmd: 114 | cmd /c (ConvertTo-ShellCommand @('fzf', '--preview-window="right:60%"', '--preview', 'echo \"quo\"')) 115 | .NOTES 116 | Not tested extensively, might not do the correct thing for all cases. 117 | .PARAMETER Command 118 | The command and its arguments. 119 | .OUTPUTS 120 | Single command string. 121 | #> 122 | function ConvertTo-ShellCommand { 123 | Param( 124 | [Parameter(Mandatory)] [String[]] $Command 125 | ) 126 | ($Command | ForEach-Object {"`"$_`""}) -join ' ' 127 | } 128 | 129 | <# 130 | .SYNOPSIS 131 | Read all lines from file, return in reversed order and unique. 132 | .DESCRIPTION 133 | Use for sorting (Get-PSReadLineOption).HistorySavePath in an MRU way for feeding into fzf. 134 | .PARAMETER Path 135 | File to read. 136 | .OUTPUTS 137 | Sorted unique lines. 138 | #> 139 | function Get-UniqueReversedLines { 140 | param( 141 | [Parameter(Mandatory)] [String] $Path 142 | ) 143 | $seen = New-Object Collections.Generic.List[String] 144 | foreach ($line in [Linq.Enumerable]::Reverse([IO.File]::ReadAllLines($Path))) { 145 | if ($line -and (-not $seen.Contains($line))) { 146 | $seen.Add($line) 147 | $line 148 | } 149 | } 150 | } 151 | 152 | <# 153 | .SYNOPSIS 154 | Convenience wrapper for [PSConsoleReadLine]::GetBufferState(). 155 | .DESCRIPTION 156 | Calls one of the overloads depending on the information needed and returns the 157 | values instead of using ref arguments. 158 | .PARAMETER Tokens 159 | Whether to return tokens, or just line and cursor position. 160 | .PARAMETER Tokens 161 | Whether to return ast/tokens/errors/cusrsor. 162 | .OUTPUTS 163 | See flag description. 164 | #> 165 | function Get-ReadlineState { 166 | param( 167 | [Parameter()] [Switch] $Tokens, 168 | [Parameter()] [Switch] $Full 169 | ) 170 | if ($Tokens -or $Full) { 171 | $ast = $null 172 | $tok = $null 173 | $errors = $null 174 | $cursor = $null 175 | [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$ast, [ref]$tok, [ref]$errors, [ref]$cursor) 176 | if ($Tokens) { 177 | $tok, $cursor 178 | } 179 | else { 180 | $ast, $tok, $errors, $cursor 181 | } 182 | } 183 | else { 184 | $line = $null 185 | $cursor = $null 186 | [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor) 187 | $line, $cursor 188 | } 189 | } 190 | 191 | <# 192 | .SYNOPSIS 193 | Check if the content before the cursor looks like a path and return that if so. 194 | .DESCRIPTION 195 | Use for determining root directory for fuzzy browsing: if the cursor is after a path 196 | we want to use that as root directory, moreover if it has 'cd' before it we want to know 197 | that as well since then we should be browsing directories. 198 | 199 | Using tokens and cursor from Get-ReadlineState -Tokens, inspect the token(s) 200 | right before the cursor and return that token if it is an existing directory, 201 | and a flag indicating whether there's a cd/Set-Location. 202 | Examples (showing commandline before cursor): 203 | -> $null 204 | cd -> $null, $True 205 | someDir -> someDir, $False 206 | cd someDir -> someDir, $True 207 | #> 208 | function Get-PathBeforeCursor { 209 | param( 210 | [Parameter(Mandatory)] [Object[]] $tokens, 211 | [Parameter(Mandatory)] [int] $cursor 212 | ) 213 | for ($i = $tokens.Length - 1; $i -ge 0; $i--) { 214 | # Last one which ends before token. 215 | if ($tokens[$i].Extent.EndOffset -le $cursor) { 216 | $text = $tokens[$i].Text 217 | $bareText = Remove-Quotes $text # Need to remove quotes for Test-Path. 218 | if ($bareText -and (Test-Path -Type Container $bareText)) { 219 | return $tokens[$i], ($i -gt 0 -and (Test-CommandIsCd $tokens[$i - 1].Text)) 220 | } 221 | elseif (Test-CommandIsCd $text) { 222 | return $null, $True 223 | } 224 | } 225 | } 226 | } 227 | 228 | <# 229 | .SYNOPSIS 230 | Add text at current cursor location, for use after fzf. 231 | .DESCRIPTION 232 | This just calls [Microsoft.PowerShell.PSConsoleReadLine]::Insert with the 233 | argument (if any) but first calls InvokePrompt because without this the 234 | prompt does not get redrawn i.e. just a black screen is shown. 235 | #> 236 | function Add-TextAtCursor { 237 | [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() 238 | if ($Args) { 239 | [Microsoft.PowerShell.PSConsoleReadLine]::Insert($Args[0]) 240 | } 241 | elseif ($Input) { 242 | [Microsoft.PowerShell.PSConsoleReadLine]::Insert($Input) 243 | } 244 | } 245 | 246 | <# 247 | .SYNOPSIS 248 | Get word before cursor, and if any add it to the fzf args as initial query. 249 | .DESCRIPTION 250 | For use in conjunction with Write-FzfResult: for many key handlers using fzf it's 251 | equally convenient/matter of preference invoking fzf then starting to type the query, 252 | vs typing the query or a part of it then invoking fzf. 253 | This function gets the current line content and builds the --query argument with it 254 | into the given fzf arguments list; the query is considered the word before the cursor 255 | position; the cursor must be directly after the word, a space then a cursor does not 256 | treat the word before the space as query. 257 | .EXAMPLE 258 | $fzfArgs, $fzQuery = Get-InitalFzfQuery $fzfArgs 259 | fzf @fzfArgs | Write-FzfResult -FzfQuery $fzQuery 260 | .PARAMETER FzfArgs 261 | Additional arguments for fzf, can be empty. 262 | .OUTPUTS 263 | Updated FzfArgs and input object for Write-FzfResult. 264 | #> 265 | function Get-InitialFzfQuery { 266 | param( 267 | [Parameter()] [Object[]] $FzfArgs = @() 268 | ) 269 | $line, $cursor = Get-ReadlineState 270 | # Anything after cursor is irrelevant, treat full word before cursor as query, 271 | # so index + 1 because word starts after the space found, 272 | # and in case of no match i.e. -1 conveniently makes 0 as query start. 273 | $queryIndex = $line.LastIndexOf(' ', $cursor) + 1 274 | if ($queryIndex -gt $cursor) { 275 | $queryIndex = $cursor 276 | } 277 | $queryLength = $cursor - $queryIndex 278 | $query = $line.SubString($queryIndex, $queryLength).Trim() 279 | # Could be cursor is at or after whitespace, so not an actual query. 280 | if ($query) { 281 | $FzfArgs += '--query' 282 | $FzfArgs += $query 283 | } 284 | $FzfArgs, @($queryIndex, $queryLength) 285 | } 286 | 287 | <# 288 | .SYNOPSIS 289 | Replace query found by Get-InitalFzfQuery with fzf result, or just insert fzf result in case of empty query. 290 | .PARAMETER FzfResult 291 | The fzf return value. No replacement/insertion happens if this is empty. 292 | .PARAMETER $FzfQuery 293 | Second output of Get-InitalFzfQuery. 294 | #> 295 | function Write-FzfResult { 296 | param( 297 | [Parameter(ValueFromPipeline)] [Object] $FzfResult, 298 | [Parameter(Mandatory)] [int[]] $FzfQuery 299 | ) 300 | [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt() 301 | if ($FzfResult) { 302 | [Microsoft.PowerShell.PSConsoleReadLine]::Replace($FzfQuery[0], $FzfQuery[1], $FzfResult) 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /PsFzfLite/PsFzfLiteBin.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Build source code into a dll in the temp directory. 4 | .DESCRIPTION 5 | Just Add-Type, but reading the input from a file so the code can be stored normally 6 | (and also tested in VS ect) instead of in one long string. Passes 'WIN' as preprocessor 7 | constant when building on Windows, and 'NET40' for a PS version less than 6. 8 | Does nothing if target file exists and source file is not newer. 9 | Otherwise deletes the file (so no other session should be using it) and compiles it again. 10 | If this errors with 'The file ... already exists' it means the assembly is in use, 11 | and as such could not be compiled again. 12 | .PARAMETER CodeFile 13 | File with source code. 14 | .PARAMETER AssemblyFileName 15 | Output dll name. 16 | .PARAMETER Force 17 | Force build even if source not newer. 18 | .PARAMETER OutputPath 19 | Output directory, defaults to temp directory. 20 | .OUTPUTS 21 | Full path to the assmbly file. 22 | #> 23 | function New-AssemblyFromSourceFile { 24 | param( 25 | [Parameter(Mandatory)] [String] $CodeFile, 26 | [Parameter(Mandatory)] [String] $AssemblyFileName, 27 | [Parameter()] [Switch] $Force, 28 | [Parameter()] [String] $OutputPath = [System.IO.Path]::GetTempPath() 29 | ) 30 | if ($OutputPath) { 31 | $assemblyFile = Join-Path $OutputPath $AssemblyFileName 32 | } 33 | else { 34 | $assemblyFile = $AssemblyFileName 35 | } 36 | if ($Force -or (-not (Test-Path $assemblyFile)) -or 37 | ((Get-ChildItem $CodeFile).LastWriteTime -gt (Get-ChildItem $assemblyFile).LastWriteTime)) { 38 | Write-Verbose "Building $assemblyFile" 39 | Remove-Item -Force $assemblyFile -ErrorAction SilentlyContinue | Out-Null 40 | $compilerOptions = @('-optimize', '-debug-', '-checked') 41 | $isPs5 = $PSVersionTable.PSVersion.Major -lt 6 42 | if ($isPs5 -or $IsWindows) { 43 | $compilerOptions += '/define:WIN' 44 | } 45 | $addTypeArgs = @{ 46 | TypeDefinition = (Get-Content -Raw $CodeFile) 47 | Language = 'CSharp' 48 | } 49 | if ($isPs5) { 50 | $compilerOptions += '/define:NET40' 51 | # PS5 has no CompilerOptions so must use parameters. 52 | $compilerParameters = [System.CodeDom.Compiler.CodeDomProvider]::GetCompilerInfo('CSharp').CreateDefaultCompilerParameters() 53 | $compilerParameters.CompilerOptions = $compilerOptions 54 | $compilerParameters.OutputAssembly = $assemblyFile 55 | # When specifying parameters we have to references manually; just 'System.Management.Automation.dll' 56 | # doesn't get found though so get the correct location by querying what this session is using. 57 | $compilerParameters.ReferencedAssemblies.Add([PSObject].Assembly.Location) | Out-Null 58 | $compilerParameters.ReferencedAssemblies.Add('System.dll') | Out-Null 59 | $compilerParameters.ReferencedAssemblies.Add('System.Core.dll') | Out-Null 60 | $addTypeArgs['CompilerParameters'] = $compilerParameters 61 | } 62 | else { 63 | $addTypeArgs['CompilerOptions'] = $compilerOptions 64 | $addTypeArgs['OutputAssembly'] = $assemblyFile 65 | } 66 | Add-Type @addTypeArgs 67 | } 68 | $assemblyFile 69 | } 70 | 71 | function New-AssemblyFromFileInThisDir { 72 | param($SourceFileName) 73 | $sourcePath = (Join-Path $PSScriptRoot "$SourceFileName.cs") 74 | # Give them different names so switching between versions is easier to test. 75 | if ($PSVersionTable.PSVersion.Major -lt 6) { 76 | $outputFile = "PsFzfLite$($SourceFileName)ps5.dll" 77 | } 78 | else { 79 | $outputFile = "PsFzfLite$SourceFileName.dll" 80 | } 81 | New-AssemblyFromSourceFile -CodeFile $sourcePath -AssemblyFileName $outputFile -Verbose 82 | } 83 | 84 | <# 85 | .SYNOPSIS 86 | Build the FileSystemWalker dll if needed, and import it. 87 | #> 88 | function Install-FileSystemWalker { 89 | Import-Module (New-AssemblyFromFileInThisDir 'FileSystemWalker' -Verbose) 90 | } 91 | 92 | <# 93 | .SYNOPSIS 94 | Build the PipeLineHelper dll if needed, and load it. 95 | #> 96 | function Install-PipelineHelper { 97 | [System.Reflection.Assembly]::LoadFile((New-AssemblyFromFileInThisDir 'PipelineHelper' -Verbose)) | Out-Null 98 | } 99 | 100 | <# 101 | .SYNOPSIS 102 | Sentinel used by Read-PipeOrTerminate. 103 | #> 104 | Set-Variable 'pipeTerminatingString' -Option Constant -Value '-PipeTerminatingString-' 105 | 106 | <# 107 | .SYNOPSIS 108 | Pass-through pipeline element which terminates pipeline when encountering $pipeTerminatingString. 109 | .DESCRIPTION 110 | At the time of writing there is no builtin way to stop a pipeline, nor do pipes detect when 111 | the downstream command stopped and instead just keep on running upstream commands. Yet that 112 | is exactly what we want to do when piping into fzf and accepting a result. There are other 113 | more complicated ways to do this (see PsFzf for instance), but this is shorter and easier to 114 | read and follow. 115 | Principle: 116 | 117 | UpstreamCommnd | cmd /c (New-PipeOrTerminateArgs 'fzf') | Read-PipeOrTerminate 118 | 119 | Once the command passed to cmd (fzf in this case) returns it will echo $pipeTerminatingString, 120 | which gets detected here and results in the pipeline being terminated. 121 | 122 | Without this, so using just GenerateInputForFzf | fzf, after making a selection in fzf or quitting 123 | it the pipe won't return until GenerateInputForFzf completes which is simply unusable 124 | for commands generating a lot of items like recursively listing files. 125 | 126 | See https://github.com/PowerShell/PowerShell/issues/15329 and linked issues for bug reports regarding 127 | pipes not detecting external command exit. 128 | See PipelineHelper.cs for the implementation for stopping the pipe. 129 | See New-InterruptibleCommand below for an alternative implementation achieving the same. 130 | .NOTES 131 | Requires Install-PipelineHelper. 132 | .PARAMETER Value 133 | Value to pass through. 134 | .OUTPUTS 135 | Value, or empty if pipe terminated. 136 | .EXAMPLE 137 | # Result will be empty if cancelled. 138 | $result = Get-ChildItem c:\ -Recurse | cmd /c (New-PipeOrTerminateArgs fzf) | Read-PipeOrTerminate 139 | #> 140 | function Read-PipeOrTerminate { 141 | [CmdletBinding()] # Needed to get a $PSCmdlet. 142 | Param( 143 | [Parameter(ValueFromPipeline)] $Value 144 | ) 145 | process { 146 | if ($Value -eq $pipeTerminatingString) { 147 | [PsFzfLite.PipelineHelper]::StopUpstreamCommands($PsCmdlet) 148 | } 149 | else { 150 | $Value 151 | } 152 | } 153 | } 154 | 155 | <# 156 | .SYNOPSIS 157 | Helper generating the arguments for the downstream Read-PipeOrTerminate command. 158 | .DESCRIPTION 159 | See Read-PipeOrTerminate. 160 | .PARAMETER Command 161 | The command to execute. 162 | .OUTPUTS 163 | Wrapped command as a string, for invocation by cmd /c or similar. 164 | #> 165 | function New-PipeOrTerminateArgs { 166 | Param( 167 | [Parameter(Mandatory)] [String] $Command 168 | ) 169 | # Returns command output line(s) if any, then pipeTerminatingString on a line, 170 | # or just pipeTerminatingString when command exits with an error. 171 | "($Command&& echo $pipeTerminatingString) || echo $pipeTerminatingString" 172 | } 173 | 174 | <# 175 | .SYNOPSIS 176 | Start an external command and terminate upstream pipe when the command exits. 177 | .DESCRIPTION 178 | See Read-PipeOrTerminate for why this is needed; this approach is a cross-platform PS-native approach, 179 | but somehwat slower. Works using a steppable pipeline, checking whether the command exited for each 180 | element fed into the pipe. For this it relies on finding the process by name which could fail for 181 | short-lived programs in which case the pipe exits immediately. But this is really meant to run fzf, 182 | so should not be an issue. 183 | Not supported for PS5. 184 | Idea from https://stackoverflow.com/a/69951585/128384. 185 | .NOTES 186 | Requires Install-PipelineHelper. 187 | .PARAMETER ExeAndArgs 188 | The command to execute and optionally its arguments. 189 | .PARAMETER InputObject 190 | Pipeline input to send to the command process. 191 | .OUTPUTS 192 | Command output. 193 | .EXAMPLE 194 | $result = Get-ChildItem c:\ -Recurse | New-InterruptibleCommand fzf 195 | #> 196 | function New-InterruptibleCommand { 197 | [CmdletBinding(PositionalBinding = $False)] 198 | param( 199 | [Parameter(Mandatory, ValueFromRemainingArguments)] [string[]] $ExeAndArgs, 200 | [Parameter(ValueFromPipeline)] $InputObject 201 | ) 202 | 203 | begin { 204 | $exe, $exeArgs = $ExeAndArgs 205 | $exeName = [IO.Path]::GetFileNameWithoutExtension($exe) 206 | try { 207 | $pipeline = ({& $exe $exeArgs}).GetSteppablePipeline($MyInvocation.CommandOrigin) 208 | $pipeline.Begin($PSCmdlet) # Culprit for PS5: doesn't do anything, only End() effectively launches. 209 | } 210 | catch { 211 | throw 212 | } 213 | # Get a reference to the newly launched process. Theoretically not 100% failsafe (could be multiple 214 | # child processes, could take . 100mSec before the command is alive) but hasn't failed so far. 215 | for ($i = 0; $i -lt 10; $i++) { 216 | Start-Sleep -Milliseconds 10 217 | $commandProcess = Get-Process -ErrorAction Ignore $exeName | 218 | Where-Object {($null -ne $_.Parent) -and ($_.Parent.Id -eq $PID)} | 219 | Select-Object -First 1 220 | if ($commandProcess) { 221 | break 222 | } 223 | } 224 | # Process block signalling object and logic. 225 | $exitedEvent = $null 226 | $processExitSignal = New-Object psobject -Property @{flag = $true} 227 | if (-not $commandProcess) { 228 | Write-Warning "Process '$exeName' unexpectedly did not appear or exited already." 229 | } 230 | else { 231 | # Use an event to detect when the process exits: polling HasExited or similar in the 232 | # process block is much simpler, but has a noticeable performance impact. 233 | $exitedEventId = 'PsFzfLite' + [System.Guid]::NewGuid() 234 | $exitedEvent = Register-ObjectEvent -InputObject $commandProcess -EventName 'Exited' ` 235 | -SourceIdentifier $exitedEventId -MessageData $processExitSignal -ErrorAction SilentlyContinue ` 236 | -Action {$Event.MessageData.flag = $true} 237 | # It's possible the process exited between the loop and the event registration attempt, 238 | # so continue processing only when we managed to get the process. 239 | if ($?) { 240 | $processExitSignal.flag = $false 241 | } 242 | } 243 | function CleanupEvent { 244 | if ($exitedEvent) { 245 | Unregister-Event $exitedEventId 246 | Stop-Job $exitedEvent 247 | Remove-Job $exitedEvent 248 | } 249 | } 250 | } 251 | 252 | process { 253 | if ($processExitSignal.flag) { 254 | # StopUpstreamCommands effectively terminates pipe so End block won't be entered: cleanup now. 255 | CleanupEvent 256 | $pipeline.End() 257 | [PsFzfLite.PipelineHelper]::StopUpstreamCommands($PsCmdlet) 258 | } 259 | $pipeline.Process($_) 260 | } 261 | 262 | end { 263 | CleanupEvent 264 | $pipeline.End() 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /PsFzfLite/FileSystemWalker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | //For testing this when building without the System.Management.Automation NuGet package. 6 | #if !VS 7 | using System.Management.Automation; 8 | #endif 9 | #if !WIN 10 | using System.IO; 11 | #else 12 | using System.Runtime.InteropServices; 13 | #endif 14 | using System.Threading; 15 | using System.Threading.Tasks; 16 | 17 | namespace PsFzfLite 18 | { 19 | #if WIN 20 | //Mostly from http://www.pinvoke.net/default.aspx/Structures/WIN32_FIND_DATA.html 21 | internal class Interop 22 | { 23 | [StructLayout( LayoutKind.Sequential, CharSet = CharSet.Ansi )] 24 | internal struct WIN32_FIND_DATA 25 | { 26 | public uint dwFileAttributes; 27 | public System.Runtime.InteropServices.ComTypes.FILETIME ftCreationTime; 28 | public System.Runtime.InteropServices.ComTypes.FILETIME ftLastAccessTime; 29 | public System.Runtime.InteropServices.ComTypes.FILETIME ftLastWriteTime; 30 | public uint nFileSizeHigh; 31 | public uint nFileSizeLow; 32 | public uint dwReserved0; 33 | public uint dwReserved1; 34 | [MarshalAs( UnmanagedType.ByValTStr, SizeConst = 260 )] 35 | public string cFileName; 36 | [MarshalAs( UnmanagedType.ByValTStr, SizeConst = 14 )] 37 | public string cAlternateFileName; 38 | } 39 | 40 | internal enum FINDEX_INFO_LEVELS 41 | { 42 | FindExInfoStandard = 0, 43 | FindExInfoBasic = 1, 44 | FindExInfoMaxInfoLevel = 2 45 | } 46 | 47 | internal enum FINDEX_SEARCH_OPS 48 | { 49 | FindExSearchNameMatch = 0, 50 | FindExSearchLimitToDirectories = 1, 51 | FindExSearchLimitToDevices = 2, 52 | FindExSearchMaxSearchOp = 3 53 | } 54 | 55 | internal const int FIND_FIRST_EX_CASE_SENSITIVE = 1; 56 | internal const int FIND_FIRST_EX_LARGE_FETCH = 2; 57 | internal const int FIND_FIRST_EX_ON_DISK_ENTRIES_ONLY = 4; 58 | internal static readonly IntPtr NULLPTR = new IntPtr( 0 ); 59 | internal static readonly IntPtr INVALID_HANDLE_VALUE = new IntPtr( -1 ); 60 | 61 | [DllImport( "kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi )] 62 | public static extern IntPtr FindFirstFileEx( 63 | string lpFileName, FINDEX_INFO_LEVELS fInfoLevelId, 64 | out WIN32_FIND_DATA lpFindFileData, FINDEX_SEARCH_OPS fSearchOp, 65 | IntPtr lpSearchFilter, int dwAdditionalFlags ); 66 | 67 | [DllImport( "kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi )] 68 | internal static extern bool FindNextFile( IntPtr hFindFile, out WIN32_FIND_DATA lpFindFileData ); 69 | 70 | [DllImport( "kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi )] 71 | internal static extern bool FindClose( IntPtr hFindFile ); 72 | } 73 | #endif 74 | 75 | /// 76 | /// Multithreaded filesystem walking tailored for piping output into PS and fzf. 77 | /// Despite this being a fairly naive and simple implementation, it works pretty well 78 | /// because listing files is I/O bound anyway, so using multiple threads just takes 79 | /// care that we hit the limit of what is possible. 80 | /// 81 | /// This is fzf-like in this sense: 82 | /// - by default returns everything found and can only filter directories 83 | /// - FilterDotDirectories defaults to what fzf does 84 | /// - can produce file paths relative to the directory being listed 85 | /// - searches either files or directories, one normally doesn't fuzzy match both 86 | /// 87 | public class FileSystemWalker 88 | { 89 | [Flags] 90 | public enum SearchType 91 | { 92 | Files = 1, 93 | Directories = 2 94 | } 95 | 96 | public static bool FilterDotDirectories( string d ) 97 | { 98 | return d.StartsWith( "." ); 99 | } 100 | 101 | #if WIN 102 | public static IEnumerable Walk( 103 | string root, string rootReplacement, SearchType searchType, 104 | Func excludeDirectories = null ) 105 | { 106 | var rootDir = root.TrimEnd( '\\', '/' ); 107 | var eraseLen = rootDir.Length + 1; // +1 for the separator 108 | //If empty we'll completely erase the root, else replace it so must have a separator. 109 | if( rootReplacement.Length > 0 && !( rootReplacement.EndsWith( "\\" ) || rootReplacement.EndsWith( "/" ) ) ) 110 | { 111 | rootReplacement += "\\"; 112 | } 113 | 114 | //All directories get pushed into this one. 115 | //Note these must always be paths ending with a separator (and we use \ because it's for windows). 116 | var dirs = new BlockingCollection { rootDir + "\\" }; 117 | //All output paths (files or directories) go into this one. 118 | var paths = new BlockingCollection(); 119 | //Walk spawns tasks which iterate over dirs, and for each list the directory 120 | //populating paths and/or dirs. 121 | var tasks = Walk( dirs, paths, searchType, excludeDirectories ); 122 | //While the tasks are producing items consume them here and output them. 123 | foreach( var file in paths.GetConsumingEnumerable() ) 124 | { 125 | yield return file.Remove( 0, eraseLen ).Insert( 0, rootReplacement ); 126 | } 127 | //All done, cleanup tasks (even though they'll all be completed by now). 128 | Task.WaitAll( tasks ); 129 | } 130 | 131 | public static Task[] Walk( 132 | BlockingCollection dirs, BlockingCollection paths, SearchType searchType, 133 | Func excludeDirectories ) 134 | { 135 | var dirsToDo = dirs.Count; 136 | if( dirsToDo == 0 ) 137 | { 138 | return new Task[] { }; 139 | } 140 | var numWorkers = Environment.ProcessorCount; 141 | var workersLeft = numWorkers; 142 | 143 | #if NET40 144 | var ConsumeDirs = new Action( () => 145 | #else 146 | void ConsumeDirs() 147 | #endif 148 | { 149 | foreach( var root in dirs.GetConsumingEnumerable() ) 150 | { 151 | Interop.WIN32_FIND_DATA fileData; 152 | var handle = Interop.FindFirstFileEx( 153 | root + "*", Interop.FINDEX_INFO_LEVELS.FindExInfoBasic, out fileData, 154 | Interop.FINDEX_SEARCH_OPS.FindExSearchNameMatch, Interop.NULLPTR, 155 | Interop.FIND_FIRST_EX_LARGE_FETCH ); 156 | if( handle != Interop.INVALID_HANDLE_VALUE ) 157 | { 158 | do 159 | { 160 | if( fileData.cFileName == "." || fileData.cFileName == ".." ) 161 | { 162 | continue; 163 | } 164 | var fullPath = root + fileData.cFileName; 165 | if( ( fileData.dwFileAttributes & 0x10 ) > 0 ) 166 | { 167 | if( excludeDirectories != null && excludeDirectories( fileData.cFileName ) ) 168 | { 169 | continue; 170 | } 171 | if( searchType.HasFlag( SearchType.Directories ) ) 172 | { 173 | paths.Add( fullPath ); 174 | } 175 | Interlocked.Increment( ref dirsToDo ); 176 | dirs.Add( fullPath + "\\" ); 177 | } 178 | else if( searchType.HasFlag( SearchType.Files ) ) 179 | { 180 | paths.Add( fullPath ); 181 | } 182 | } while( Interop.FindNextFile( handle, out fileData ) ); 183 | Interop.FindClose( handle ); 184 | } 185 | if( Interlocked.Decrement( ref dirsToDo ) == 0 ) 186 | { 187 | dirs.CompleteAdding(); 188 | } 189 | } 190 | if( Interlocked.Decrement( ref workersLeft ) == 0 ) 191 | { 192 | paths.CompleteAdding(); 193 | } 194 | } 195 | #if NET40 196 | ); 197 | #endif 198 | 199 | return Enumerable.Range( 1, numWorkers ).Select( n => Task.Factory.StartNew( ConsumeDirs ) ).ToArray(); 200 | } 201 | 202 | #else //#if WIN 203 | 204 | public static IEnumerable Walk( 205 | string root, string rootReplacement, SearchType searchType, 206 | Func excludeDirectories = null ) 207 | { 208 | var rootDir = root.TrimEnd( '\\', '/' ); 209 | var eraseLen = rootDir.Length + 1; 210 | if( rootReplacement.Length > 0 && !( rootReplacement.EndsWith( "\\" ) || rootReplacement.EndsWith( "/" ) ) ) 211 | { 212 | rootReplacement += Path.DirectorySeparatorChar; 213 | } 214 | 215 | var dirs = new BlockingCollection { new DirectoryInfo( rootDir ) }; 216 | var paths = new BlockingCollection(); 217 | var tasks = Walk( dirs, paths, searchType, excludeDirectories ); 218 | foreach( var file in paths.GetConsumingEnumerable() ) 219 | { 220 | yield return file.Remove( 0, eraseLen ).Insert( 0, rootReplacement ); 221 | } 222 | Task.WaitAll( tasks ); 223 | } 224 | 225 | public static Task[] Walk( 226 | BlockingCollection dirs, BlockingCollection paths, SearchType searchType, 227 | Func excludeDirectories ) 228 | { 229 | var dirsToDo = dirs.Count; 230 | if( dirsToDo == 0 ) 231 | { 232 | return new Task[] { }; 233 | } 234 | var numWorkers = Environment.ProcessorCount; 235 | var workersLeft = numWorkers; 236 | 237 | #if NET40 238 | var ConsumeDirs = new Action( () => 239 | #else 240 | void ConsumeDirs() 241 | #endif 242 | { 243 | foreach( var root in dirs.GetConsumingEnumerable() ) 244 | { 245 | try 246 | { 247 | foreach( var dir in root.EnumerateDirectories( "*", SearchOption.TopDirectoryOnly ) ) 248 | { 249 | if( excludeDirectories != null && excludeDirectories( dir.Name ) ) 250 | { 251 | continue; 252 | } 253 | if( searchType.HasFlag( SearchType.Directories ) ) 254 | { 255 | paths.Add( dir.FullName ); 256 | } 257 | Interlocked.Increment( ref dirsToDo ); 258 | dirs.Add( dir ); 259 | } 260 | } 261 | catch( Exception ) 262 | { 263 | } 264 | if( searchType.HasFlag( SearchType.Files ) ) 265 | { 266 | try 267 | { 268 | foreach( var file in root.EnumerateFiles( "*", SearchOption.TopDirectoryOnly ) ) 269 | { 270 | paths.Add( file.FullName ); 271 | } 272 | } 273 | catch( Exception ) 274 | { 275 | } 276 | } 277 | if( Interlocked.Decrement( ref dirsToDo ) == 0 ) 278 | { 279 | dirs.CompleteAdding(); 280 | } 281 | } 282 | if( Interlocked.Decrement( ref workersLeft ) == 0 ) 283 | { 284 | paths.CompleteAdding(); 285 | } 286 | } 287 | #if NET40 288 | ); 289 | #endif 290 | 291 | return Enumerable.Range( 1, numWorkers ).Select( n => Task.Factory.StartNew( ConsumeDirs ) ).ToArray(); 292 | } 293 | #endif 294 | } 295 | 296 | #if !VS 297 | [Cmdlet( VerbsCommon.Get, "ChildPathNames" )] 298 | [OutputType( typeof( string ) )] 299 | public class GetChildPathNamesCmdlet : PSCmdlet 300 | { 301 | public GetChildPathNamesCmdlet() 302 | { 303 | PathReplacement = ""; // Default: return paths relative to Path. 304 | SearchType = FileSystemWalker.SearchType.Files; 305 | Filter = FileSystemWalker.FilterDotDirectories; 306 | } 307 | 308 | [Parameter( Mandatory = true )] 309 | public string Path { get; set; } 310 | 311 | [Parameter()] 312 | public string PathReplacement { get; set; } 313 | 314 | [Parameter()] 315 | public FileSystemWalker.SearchType SearchType { get; set; } 316 | 317 | [Parameter()] 318 | public Func Filter { get; set; } 319 | 320 | protected override void ProcessRecord() 321 | { 322 | var path = GetUnresolvedProviderPathFromPSPath( Path ); 323 | foreach( var i in FileSystemWalker.Walk( path, PathReplacement, SearchType, Filter ) ) 324 | { 325 | WriteObject( i ); 326 | } 327 | } 328 | } 329 | #endif 330 | } 331 | --------------------------------------------------------------------------------