private/Core.ps1
${Script:/} = [IO.Path]::DirectorySeparatorChar $Script:esc = [char]27 # for PS <7 $Script:undoStack = [Collections.Stack]::new() $Script:redoStack = [Collections.Stack]::new() $Script:recent = [Collections.Generic.Dictionary[string, RecentDir]]::new() $Script:logger = { Write-Verbose ($args[0] | ConvertTo-Json) } $Script:background = $null function DefaultIfEmpty([scriptblock] $default) { Begin { $any = $false } Process { if ($_) { $any = $true; $_ } } End { if (!$any) { &$default } } } filter Truncate([int] $maxLength = $cde.MaxMenuLength) { if (!$_ -or $_.Length -le $maxLength) { return $_ } if ($_.StartsWith($esc)) { TruncatedColoured $_ $maxLength } else { $_.Substring(0, $maxLength - 1) + [char]0x2026 # ellipsis } } function TruncatedColoured([string]$string, $maxLen) { $textStart = $string.IndexOf('m') + 1 $startFinalEscapeSequence = $string.LastIndexOf($esc) $textEnd = if ($startFinalEscapeSequence -gt $textStart) { $startFinalEscapeSequence } else { $string.Length - 1 } $text = $string.Substring($textStart, $textEnd - $textStart) if ($text.Length -le $maxLen) { $string } else { $string.Substring(0, $textStart) + ($text | Truncate) + "$esc[0m" } } filter IsRootedOrRelative { ($_ | IsRooted) -or ($_ | IsRelative) } filter IsRooted { [System.IO.Path]::IsPathRooted($_) -or $_ -match '~(/|\\)*' # also consider the path rooted if it's relative to home } filter IsRelative { $_ -match '^+\.' # e.g. starts with ./, ../, ... } filter IsDescendedFrom($maybeAncestor) { ($_ | Get-Ancestors).Path -contains ($maybeAncestor | Resolve-Path) } filter NormaliseAndEscape { $_ | Normalise | Escape } filter Normalise { $_ -replace '/|\\', ${/} } filter Escape { [regex]::Escape($_) } filter RemoveSurroundingQuotes { ($_ -replace "^'", '') -replace "'$", '' } filter SurroundAndTerminate($trailChar) { if ($_ -notmatch ' |\[|\]') { "$_$trailChar" } else { "'$_$trailChar'" } } filter RemoveTrailingSeparator { if ($_ -match '[/\\].*?([/\\])$') { $_.TrimEnd('/', '\') } else { $_ } } filter EscapeWildcards { [WildcardPattern]::Escape($_) } function GetBestIndex([array]$array, [string]$namepart) { ( $items = $array -eq ($namepart | Normalise | RemoveTrailingSeparator) # full path match ) -or ( $items = $array.Where{ ($_ | Split-Path -Leaf) -eq $namepart } # full leaf match ) -or ( $items = $array.Where{ ($_ | Split-Path -Leaf) -Match "^$($namepart | NormaliseAndEscape)" } # leaf starts with ) -or ( $items = $array -match ($namepart | NormaliseAndEscape) # anything... ) | Out-Null [array]::indexOf($array, ($items | select -First 1)) } function IndexedComplete([bool] $IndexedCompletion = $cde.IndexedCompletion) { Begin { $items = @() } Process { $items += $_ } End { $items | % { $completionText = if ($IndexedCompletion -and @($items).Count -gt 1) { "$($_.n)" } else { $_.path | SurroundAndTerminate } $listItemText = "$($_.n). $($_.name)" $tooltip = if ($_.name -ne $_.path) { "$($_.n). $($_.path)" } else { "$($_.n). ($($_.path))" } [Management.Automation.CompletionResult]::new( $completionText, $listItemText, 'ParameterValue', $tooltip ) } } } function IndexPaths( [array]$xs, $rootLabel = 'root' # this on happens on *nix ) { $xs = $xs -ne '' | Select -Unique if (!$xs) { return @() } $i = 0 $xs.ForEach{ [IndexedPath] @{ n = ++$i Name = $_ | Split-Path -Leaf | DefaultIfEmpty { $rootLabel } Path = $_ } } } function RegisterCompletions([string[]] $commands, $param, $target) { Register-ArgumentCompleter -CommandName $commands -ParameterName $param -ScriptBlock $target } function ImportRecent() { $dirs = Import-Csv $cde.RECENT_DIRS_FILE $cde.recentHash = if ($h = Get-FileHash -LiteralPath $cde.RECENT_DIRS_FILE) { $h.Hash.ToString() } $dirs.ForEach{ $dir = [RecentDir]$_ $dir.Favour = $_.Favour -and [bool]::Parse($_.Favour) $recent[$_.Path] = $dir } TrimRecent } function RefreshRecent() { if (!$cde.RECENT_DIRS_FILE -or !(Test-Path $cde.RECENT_DIRS_FILE)) { return } try { if ($hasMutex = $cde.mutex.WaitOne(1)) { $currentHash = (Get-FileHash -LiteralPath $cde.RECENT_DIRS_FILE).Hash.ToString() if ($currentHash -ne $cde.recentHash) { WriteLog ($currentHash, $cde.recentHash) ImportRecent } } } finally { if ($hasMutex) { $cde.mutex.ReleaseMutex() } } } function RecentsByTermWithSort([int] $first, [string[]] $terms, [scriptblock] $sort) { function MatchesTerms([string] $path) { function MatchPath($terms, $idx = 0) { $fst, $rst = $terms if (!$fst) { return $true } $nextIdx = $path.IndexOf($fst, $idx, [StringComparison]::CurrentCultureIgnoreCase) return ($nextIdx -ge 0) -and (MatchPath $rst ($nextIdx + $fst.Length)) } function MatchLeaf($term) { (Split-Path -Leaf $path) -match $term } if (!$terms) { return $true } (MatchPath ($terms | Normalise)) -and (MatchLeaf ($terms[-1] | NormaliseAndEscape)) } RefreshRecent $recent.Values.Where( { ($_.Path -ne ($pwd | RemoveTrailingSeparator)) -and (MatchesTerms $_.Path) }) | Sort-Object $sort -Descending | select -First $first -Expand Path } function GetFrecent([int] $first, [string[]] $terms) { function FrecencyFactor([uint64] $lastEntered) { $now = [System.DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() if ($lastEntered -gt ($now - 1000 * 60 * 60)) { 4 } # past hour elseif ($lastEntered -gt ($now - 1000 * 60 * 60 * 24)) { 2 } # past day elseif ($lastEntered -gt ($now - 1000 * 60 * 60 * 24 * 7)) { 1 / 2 } # past week else { 1 / 4 } } function FavourFactor([bool] $isFavoured) { ([int]$isFavoured * 1000) + 1 } RecentsByTermWithSort $first $terms { $_.EnterCount * (FrecencyFactor $_.LastEntered) * (FavourFactor $_.Favour) } } function GetRecent([int] $first, [string[]] $terms) { RecentsByTermWithSort $first $terms { $_.LastEntered } } function UpdateRecent($path, $favour = $false) { $path = $path | RemoveTrailingSeparator if ($path -in $cde.RECENT_DIRS_EXCLUDE) { return } $entry = if (($current = $recent[$path])) { $current } else { [RecentDir] @{ Path = $path; EnterCount = $favour } } if (!$favour) { $entry.LastEntered = [System.DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() $entry.EnterCount++ } else { $entry.Favour = $true } $recent[$path] = $entry TrimRecent PersistRecent } function Unfavour([RecentDir] $dir) { if (!$dir.LastEntered) { RemoveRecent(@($dir.Path)) } else { $dir.Favour = $false PersistRecent } } function RemoveRecent([string[]] $dirs) { $dirs | % { $recent.remove($_) } | Out-Null PersistRecent } function TrimRecent() { if ($recent.Count -gt $cde.MaxRecentDirs) { RemoveRecent ( $recent.Values | Sort-Object Favour, LastEntered | select -First ($recent.Count - $cde.MaxRecentDirs) -expand Path) } } function PersistRecent() { if ($cde.RECENT_DIRS_FILE -and $recent.Count) { if (!$background) { InitRunspace } try { if ($hasMutex = $cde.mutex.WaitOne(1000)) { $background.Stop() $null = $background.BeginInvoke() } else { WriteLog 'Recent dirs file in use' } } finally { if ($hasMutex) { $cde.mutex.ReleaseMutex() } } } } function InitRunspace() { # infra for backgrounding recent dirs persistence $Script:background = [PowerShell]::Create() $null = $background.AddScript( { try { if ($hasMutex = $cde.mutex.WaitOne(1000)) { $recent.Values | Export-Csv -LiteralPath $cde.RECENT_DIRS_FILE Write-Verbose ($cde.recentHash = (Get-FileHash $cde.RECENT_DIRS_FILE).Hash.ToString()) } } finally { if ($hasMutex) { $cde.mutex.ReleaseMutex() } } }) $runspace = [RunspaceFactory]::CreateRunspace() $runspace.Open() $runspace.SessionStateProxy.SetVariable('recent', $recent) $runspace.SessionStateProxy.SetVariable('cde', $cde) $background.Runspace = $runspace } function WriteLog($message) { $m = if ($message) { $message } else { '[null]' } &$logger $m } |