Private/Resolve.ps1
|
# Resolution of the colour associated with a folder. # # The tree is walked up from the current folder. The first ancestor folder that # provides a colour wins ([nearest wins] semantics, like .editorconfig). Within a # single folder, the priority order is: # # 1. .terminalcolors.json (this module's own configuration) # 2. .vscode/settings.json (VS Code's Peacock extension) # 3. .vs/**/color.txt (Visual Studio's Solution Colors extension) # 4. .git (automatic colour derived from the repository name) $script:TcConfigNames = @('.terminalcolors.json', 'terminalcolors.json', '.terminalcolors') $script:TcResolveCache = @{} $script:TcNegativeCacheSeconds = 5 function Clear-TcResolveCache { $script:TcResolveCache = @{} } function New-TcColorInfo { param( [hashtable] $Rgb, [string] $Name, [string] $Icon, $Tint, [string] $Source, [string] $SourcePath, [string] $Root ) return [pscustomobject]@{ Color = ConvertTo-TcHex -Rgb $Rgb Rgb = $Rgb Name = $Name Icon = $Icon Tint = $Tint Source = $Source SourcePath = $SourcePath Root = $Root } } function Resolve-TcConfigFile { <# .SYNOPSIS Reads a .terminalcolors.json file. Handles [auto], per-branch overrides and applyToSubfolders. #> [CmdletBinding()] param([string] $Directory, [string] $Path, [string] $LeafDirectory) $json = ConvertFrom-TcJsonFile -Path $Path if ($null -eq $json) { Write-Verbose "TerminalColors: [$Path] ignored (invalid JSON)." return $null } # Scope: by default the colour applies to subfolders. $applyToSub = Get-TcJsonProperty -InputObject $json -Name 'applyToSubfolders' if ($applyToSub -is [bool] -and -not $applyToSub) { if ($LeafDirectory -ne $Directory) { return $null } } $colorText = Get-TcJsonProperty -InputObject $json -Name 'color' # Possible per-Git-branch override $branches = Get-TcJsonProperty -InputObject $json -Name 'branches' if ($branches) { $branch = Get-TcGitBranch -Path $Directory if (-not $branch) { $gitRoot = Get-TcGitRootFrom -Path $Directory if ($gitRoot) { $branch = Get-TcGitBranch -Path $gitRoot } } if ($branch) { foreach ($prop in $branches.PSObject.Properties) { if ($branch -like $prop.Name) { $colorText = $prop.Value; break } } } } if ([string]::IsNullOrWhiteSpace([string]$colorText)) { return $null } $name = [string](Get-TcJsonProperty -InputObject $json -Name 'name') if ([string]::IsNullOrWhiteSpace($name)) { $name = Split-Path $Directory -Leaf } if ([string]$colorText -eq 'auto') { $rgb = Get-TcAutoColor -Seed $name } else { $rgb = ConvertFrom-TcColor -Value ([string]$colorText) } if ($null -eq $rgb) { Write-Verbose "TerminalColors: colour [$colorText] not recognised in [$Path]." return $null } $icon = Get-TcJsonProperty -InputObject $json -Name 'icon' $tintValue = Get-TcJsonProperty -InputObject $json -Name 'tint' $tint = $null if ($null -ne $tintValue) { try { $tint = [double]$tintValue } catch { $tint = $null } } return New-TcColorInfo -Rgb $rgb -Name $name -Icon ([string]$icon) -Tint $tint ` -Source 'Config' -SourcePath $Path -Root $Directory } function Resolve-TcPeacock { <# .SYNOPSIS Reads the Peacock extension's colour from .vscode/settings.json. #> [CmdletBinding()] param([string] $Directory, [string] $Path) $json = ConvertFrom-TcJsonFile -Path $Path if ($null -eq $json) { return $null } $color = Get-TcJsonProperty -InputObject $json -Name 'peacock.color' if ([string]::IsNullOrWhiteSpace([string]$color)) { # Peacock has only been writing peacock.color since a recent version: fall # back on the customisations it applies anyway. $custom = Get-TcJsonProperty -InputObject $json -Name 'workbench.colorCustomizations' foreach ($key in @('titleBar.activeBackground', 'activityBar.background', 'statusBar.background')) { $candidate = Get-TcJsonProperty -InputObject $custom -Name $key if (-not [string]::IsNullOrWhiteSpace([string]$candidate)) { $color = $candidate; break } } } if ([string]::IsNullOrWhiteSpace([string]$color)) { return $null } $rgb = ConvertFrom-TcColor -Value ([string]$color) if ($null -eq $rgb) { return $null } return New-TcColorInfo -Rgb $rgb -Name (Split-Path $Directory -Leaf) -Icon '' -Tint $null ` -Source 'Peacock' -SourcePath $Path -Root $Directory } function Resolve-TcSolutionColors { <# .SYNOPSIS Reads the colour of the Visual Studio [Solution Colors] extension. Format of .vs\<Solution>\color.txt: one [branch:Colour] line per branch (the legacy format contains only [Colour]). #> [CmdletBinding()] param([string] $Directory, [string] $Path) try { $lines = [System.IO.File]::ReadAllLines($Path) } catch { return $null } $entries = @() foreach ($line in $lines) { if ([string]::IsNullOrWhiteSpace($line)) { continue } $segments = $line.Split(':') if ($segments.Count -eq 1) { $entries += [pscustomobject]@{ Branch = 'master'; Color = $segments[0].Trim() } } else { $entries += [pscustomobject]@{ Branch = $segments[0].Trim(); Color = $segments[1].Trim() } } } if ($entries.Count -eq 0) { return $null } $branch = Get-TcGitBranch -Path $Directory if (-not $branch) { $gitRoot = Get-TcGitRootFrom -Path $Directory if ($gitRoot) { $branch = Get-TcGitBranch -Path $gitRoot } } $chosen = $null if ($branch) { $chosen = $entries | Where-Object { $_.Branch -eq $branch } | Select-Object -First 1 } if (-not $chosen) { $chosen = $entries | Where-Object { $_.Branch -eq 'master' } | Select-Object -First 1 } if (-not $chosen) { $chosen = $entries[0] } $rgb = ConvertFrom-TcColor -Value $chosen.Color if ($null -eq $rgb) { return $null } # also covers [None] # The .vs\<Solution> folder name is more meaningful than the directory's. $solutionName = Split-Path (Split-Path $Path -Parent) -Leaf if ($solutionName -eq '.vs') { $solutionName = Split-Path $Directory -Leaf } return New-TcColorInfo -Rgb $rgb -Name $solutionName -Icon '' -Tint $null ` -Source 'SolutionColors' -SourcePath $Path -Root $Directory } function Get-TcSolutionColorFile { [CmdletBinding()] param([string] $Directory) $vs = Join-Path $Directory '.vs' if (-not [System.IO.Directory]::Exists($vs)) { return $null } $direct = Join-Path $vs 'color.txt' if ([System.IO.File]::Exists($direct)) { return $direct } try { $found = [System.IO.Directory]::GetFiles($vs, 'color.txt', [System.IO.SearchOption]::AllDirectories) } catch { return $null } # The @() is essential: without it, a single-element pipeline returns a string, # and [0] would extract its first character. $sorted = @($found | Sort-Object) if ($sorted.Count -gt 0) { return $sorted[0] } return $null } function Get-TcGitRootFrom { [CmdletBinding()] param([string] $Path) $current = $Path while ($current) { if (Test-TcGitRoot -Path $current) { return $current } $parent = [System.IO.Path]::GetDirectoryName($current) if ($parent -eq $current) { break } $current = $parent } return $null } function Resolve-TcDirectory { <# .SYNOPSIS Looks for a colour declared in one specific folder (without walking up). #> [CmdletBinding()] param( [string] $Directory, [string] $LeafDirectory, [bool] $AutoGitColors ) foreach ($name in $script:TcConfigNames) { $path = Join-Path $Directory $name if ([System.IO.File]::Exists($path)) { $info = Resolve-TcConfigFile -Directory $Directory -Path $path -LeafDirectory $LeafDirectory if ($info) { return $info } } } $vscode = Join-Path (Join-Path $Directory '.vscode') 'settings.json' if ([System.IO.File]::Exists($vscode)) { $info = Resolve-TcPeacock -Directory $Directory -Path $vscode if ($info) { return $info } } $colorFile = Get-TcSolutionColorFile -Directory $Directory if ($colorFile) { $info = Resolve-TcSolutionColors -Directory $Directory -Path $colorFile if ($info) { return $info } } if ($AutoGitColors -and (Test-TcGitRoot -Path $Directory)) { $name = Split-Path $Directory -Leaf $rgb = Get-TcAutoColor -Seed $name return New-TcColorInfo -Rgb $rgb -Name $name -Icon '' -Tint $null ` -Source 'GitRepository' -SourcePath (Join-Path $Directory '.git') -Root $Directory } return $null } function Resolve-TcColor { <# .SYNOPSIS Resolves the colour applicable to a path by walking up the tree. Returns $null when no ancestor defines a colour. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Path, [bool] $AutoGitColors = $true, [switch] $NoCache ) $leaf = $Path.TrimEnd('\', '/') if ([string]::IsNullOrWhiteSpace($leaf)) { return $null } $cacheKey = "$($leaf.ToLowerInvariant())|$AutoGitColors" if (-not $NoCache -and $script:TcResolveCache.ContainsKey($cacheKey)) { $entry = $script:TcResolveCache[$cacheKey] if ($null -eq $entry.Info) { # Negative result: short lifetime, so a configuration file that has just # been created is picked up quickly. if (((Get-Date) - $entry.Stamp).TotalSeconds -lt $script:TcNegativeCacheSeconds) { return $null } } else { $stamp = Get-TcFileStamp -Path $entry.Info.SourcePath if ($stamp -eq $entry.Stamp) { return $entry.Info } } } $info = $null $current = $leaf while ($current) { $info = Resolve-TcDirectory -Directory $current -LeafDirectory $leaf -AutoGitColors $AutoGitColors if ($info) { break } $parent = [System.IO.Path]::GetDirectoryName($current) if (-not $parent -or $parent -eq $current) { break } $current = $parent } if ($info) { $script:TcResolveCache[$cacheKey] = @{ Info = $info; Stamp = (Get-TcFileStamp -Path $info.SourcePath) } } else { $script:TcResolveCache[$cacheKey] = @{ Info = $null; Stamp = (Get-Date) } } return $info } function Get-TcFileStamp { param([string] $Path) try { if ([System.IO.File]::Exists($Path)) { return [System.IO.File]::GetLastWriteTimeUtc($Path).Ticks } if ([System.IO.Directory]::Exists($Path)) { return [System.IO.Directory]::GetLastWriteTimeUtc($Path).Ticks } } catch { } return 0 } |