lib/vscode.ps1
|
# VS Code's integrated terminal: the fourth config writer. # # WHAT VS CODE CAN AND CANNOT DO, because the difference decides the whole # shape of this file. `workbench.colorCustomizations` exposes every colour a # scheme carries -- terminal.background, terminal.foreground, the cursor, and # all sixteen terminal.ansi* IDs -- and `terminal.integrated.*` covers the font # and the cursor shape. There is NO background image: `terminal.background` is # a solid colour, the extension API has no hook for one, and the request has # been open since 2016. The extensions that appear to do it rewrite VS Code's # own CSS on disk, which earns the "installation appears corrupt" warning and # breaks on every update. So `BackgroundImage` stays $false for this kind and # Get-UnsupportedStyleField says so on screen -- claiming it would be exactly # the false promise CLAUDE.md's capability rule exists to prevent. # # JSONC, AND WHAT A WRITE COSTS. settings.json is JSON with comments, same as # Windows Terminal's, so Remove-JsonComment already solves the parse. The write # is the problem: a parse/serialise round trip drops every comment and all # formatting, and people hand-maintain this file far more than they do # settings.json for a terminal. That is a real cost, not a footnote -- see the # note on Merge-StyleIntoVSCodeSettings. Nothing here writes a file; the # functions take a parsed object and give one back, which is also what makes # them testable without going near anyone's real editor. # # The shape mirrors lib/wtsettings.ps1 on purpose. Two writers that merge a # style into a JSONC config should not invent two vocabularies for it. function Get-VSCodeSettingsPath { <# .SYNOPSIS Where a VS Code variant keeps its user settings.json. .DESCRIPTION Four families ship the same settings file under different directory names: stable, Insiders, VSCodium and the forks (Cursor, Windsurf). They can be installed side by side, and a user running Insiders has no settings.json at the stable path at all -- so the variant is a parameter, never a guess. -Platform / -HomeDir / -AppData are test seams; real callers omit them. Returns a path string. It does NOT check the file exists: a variant that is installed but never opened has a User directory and no settings.json yet, and "not there" is the caller's decision to make, not this function's. The answer depends on -Platform and on NOTHING ABOUT THE HOST. Neither of the obvious joins gets that right: Join-Path goes through the PowerShell provider and resolves the drive, so joining onto 'C:\Users\x' throws "a drive with the name 'C' does not exist" on macOS and Linux -- which is where the Windows branch gets exercised. [System.IO.Path]::Combine has no opinion about drives, but it separates with the HOST's character. Asked for a macOS path on Windows it returns '/Users/x\Library\Application Support\...', which is not a path on either system. CI caught exactly that: both Windows legs red, both Unix legs green, on a function whose output should not have known the difference. So the separator comes from the platform being ASKED ABOUT. A parameterised answer that varies by machine is not a parameterised answer. #> [CmdletBinding()] param( [ValidateSet('Code', 'Code - Insiders', 'VSCodium', 'Cursor', 'Windsurf')] [string]$Variant = 'Code', [string]$Platform = (Get-TStylesPlatform), [string]$HomeDir = $HOME, [string]$AppData = $env:APPDATA, [string]$XdgConfigHome = $env:XDG_CONFIG_HOME ) $sep = Get-PlatformPathSeparator -Platform $Platform switch ($Platform) { 'Windows' { # %APPDATA% (Roaming), not LOCALAPPDATA: settings.json is the file # that follows a roaming profile, which is why VS Code puts it # there and the extension cache elsewhere. $root = if ($AppData) { $AppData } else { Join-PlatformPath -Separator $sep -Parts @($HomeDir, 'AppData', 'Roaming') } return (Join-PlatformPath -Separator $sep -Parts @($root, $Variant, 'User', 'settings.json')) } 'MacOS' { return (Join-PlatformPath -Separator $sep -Parts @($HomeDir, 'Library', 'Application Support', $Variant, 'User', 'settings.json')) } default { # Linux and anything else XDG-shaped. # # A BOUND -HomeDir suppresses the ambient XDG_CONFIG_HOME, the same # way a bound -HomeDir suppresses $env:ZDOTDIR everywhere else in # this repo. Reading the live variable through a seam is how the # ubuntu leg went red while the other three passed: the runner sets # XDG_CONFIG_HOME, so the caller said where home was and the # machine answered anyway. Binding both is still honoured -- that # caller is asking about an XDG layout on purpose. $xdg = $XdgConfigHome if ($PSBoundParameters.ContainsKey('HomeDir') -and -not $PSBoundParameters.ContainsKey('XdgConfigHome')) { $xdg = $null } $cfg = if ($xdg) { $xdg } else { Join-PlatformPath -Separator $sep -Parts @($HomeDir, '.config') } return (Join-PlatformPath -Separator $sep -Parts @($cfg, $Variant, 'User', 'settings.json')) } } } function Get-PlatformPathSeparator { # The separator a given platform uses, NOT the one this machine uses. [CmdletBinding()] param([Parameter(Mandatory)][string]$Platform) if ($Platform -eq 'Windows') { return '\' } return '/' } function Join-PlatformPath { # Join path parts with an explicit separator. Pure string work: no # provider, no drive resolution, no [System.IO.Path], and so no dependence # on the host. Trailing separators on a part are trimmed so a HomeDir of # 'C:\Users\x\' does not produce a doubled one. [CmdletBinding()] param( [Parameter(Mandatory)][string]$Separator, [Parameter(Mandatory)][string[]]$Parts ) return (@($Parts | ForEach-Object { "$_".TrimEnd('\', '/') }) -join $Separator) } function Get-VSCodeTerminalColor { <# .SYNOPSIS A scheme's colours as the theme-color IDs VS Code actually reads. .DESCRIPTION Pure. Returns an ordered hashtable of <theme colour ID> -> <hex>, holding only the IDs the scheme has a value for: a scheme with no selectionBackground must not write an empty string, which VS Code reports as an invalid colour rather than ignoring. Note `purple` -> `ansiMagenta`. scheme.json speaks Windows Terminal's vocabulary and VS Code speaks ANSI's; this is the one place the two names for slot 5 are reconciled, so nothing downstream has to know. #> [CmdletBinding()] param([Parameter(Mandatory)]$Scheme) # <scheme field> -> <VS Code theme colour ID> $map = [ordered]@{ 'background' = 'terminal.background' 'foreground' = 'terminal.foreground' 'cursorColor' = 'terminalCursor.foreground' 'selectionBackground' = 'terminal.selectionBackground' 'black' = 'terminal.ansiBlack' 'red' = 'terminal.ansiRed' 'green' = 'terminal.ansiGreen' 'yellow' = 'terminal.ansiYellow' 'blue' = 'terminal.ansiBlue' 'purple' = 'terminal.ansiMagenta' 'cyan' = 'terminal.ansiCyan' 'white' = 'terminal.ansiWhite' 'brightBlack' = 'terminal.ansiBrightBlack' 'brightRed' = 'terminal.ansiBrightRed' 'brightGreen' = 'terminal.ansiBrightGreen' 'brightYellow' = 'terminal.ansiBrightYellow' 'brightBlue' = 'terminal.ansiBrightBlue' 'brightPurple' = 'terminal.ansiBrightMagenta' 'brightCyan' = 'terminal.ansiBrightCyan' 'brightWhite' = 'terminal.ansiBrightWhite' } $out = [ordered]@{} foreach ($field in $map.Keys) { $value = $null if ($Scheme.PSObject.Properties[$field]) { $value = $Scheme.$field } if ($value -is [string] -and $value.Trim()) { $out[$map[$field]] = $value } } return $out } function Get-VSCodeCursorStyle { <# .SYNOPSIS A theme.json cursorShape as terminal.integrated.cursorStyle. .DESCRIPTION Pure. VS Code takes exactly three: block, line, underline. Windows Terminal has five, so two of them have no exact answer and are mapped to the nearest shape rather than dropped -- an unset cursorStyle leaves the user's own, which is a different style's cursor, not this one's. Returns $null for a shape this does not know, which the caller treats as "write nothing". #> [CmdletBinding()] param([string]$CursorShape) switch ("$CursorShape".ToLowerInvariant()) { 'filledbox' { return 'block' } 'emptybox' { return 'block' } # VS Code has no hollow cursor. 'bar' { return 'line' } 'underscore' { return 'underline' } 'vintage' { return 'underline' } # WT's vintage is a filled underscore. default { return $null } } } function Get-VSCodeFontWeight { <# .SYNOPSIS A theme.json font weight as terminal.integrated.fontWeight. .DESCRIPTION Pure. VS Code takes 'normal', 'bold', or a number 1-1000. Windows Terminal takes CSS-style names, and the ones that are not normal/bold have to become numbers or VS Code rejects the whole setting. Returns $null for a weight this does not know. #> [CmdletBinding()] param([string]$Weight) switch ("$Weight".ToLowerInvariant()) { 'thin' { return 100 } 'extra-light' { return 200 } 'light' { return 300 } 'normal' { return 'normal' } 'medium' { return 500 } 'semi-bold' { return 600 } 'bold' { return 'bold' } 'extra-bold' { return 800 } 'black' { return 900 } default { return $null } } } function Set-JsonMember { # Set a property on a PSCustomObject whether or not it is already there. # ConvertFrom-Json gives PSCustomObject on every engine this ships to # (-AsHashtable is pwsh-only), so this is the one way to write a key. [CmdletBinding()] param( [Parameter(Mandatory)]$Object, [Parameter(Mandatory)][string]$Name, $Value ) if ($Object.PSObject.Properties[$Name]) { $Object.PSObject.Properties[$Name].Value = $Value } else { $Object | Add-Member -NotePropertyName $Name -NotePropertyValue $Value } } function Get-VSCodeStyleSetting { <# .SYNOPSIS Everything a style asks VS Code for, as flat <setting key> -> <value>. .DESCRIPTION Pure, and deliberately the ONLY place that decides what a style means for VS Code. Both the merge and the removal read it, so "what did we write" and "what should we take back" cannot drift into two answers -- the defect shape CLAUDE.md warns about, and the one that had a registration list and a removal list disagreeing here before. The terminal colours are returned nested under 'workbench.colorCustomizations' because that is how they sit in the file; everything else is flat. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$StyleDir) $enc = [System.Text.UTF8Encoding]::new($false) $scheme = [System.IO.File]::ReadAllText((Join-Path $StyleDir 'scheme.json'), $enc) | ConvertFrom-Json $theme = $null $themePath = Join-Path $StyleDir 'theme.json' if (Test-Path -LiteralPath $themePath) { try { $theme = [System.IO.File]::ReadAllText($themePath, $enc) | ConvertFrom-Json } catch { } } $flat = [ordered]@{} if ($theme) { if ($theme.font -and $theme.font.face) { $flat['terminal.integrated.fontFamily'] = $theme.font.face } if ($theme.font -and $theme.font.size) { $flat['terminal.integrated.fontSize'] = $theme.font.size } if ($theme.font -and $theme.font.weight) { $w = Get-VSCodeFontWeight -Weight $theme.font.weight if ($null -ne $w) { $flat['terminal.integrated.fontWeight'] = $w } } $cursor = Get-VSCodeCursorStyle -CursorShape $theme.cursorShape if ($cursor) { $flat['terminal.integrated.cursorStyle'] = $cursor } } return [ordered]@{ Colors = (Get-VSCodeTerminalColor -Scheme $scheme) Settings = $flat } } function Merge-StyleIntoVSCodeSettings { <# .SYNOPSIS Merge a style into a parsed settings.json object. Returns the object. .DESCRIPTION Takes what ConvertFrom-Json gave and gives it back merged. It writes no file, which is what lets a test drive it without going near a real editor -- and it is the caller's job to take the first-touch backup before serialising, because a round trip through ConvertTo-Json DROPS EVERY COMMENT AND ALL FORMATTING in the file. settings.json is hand-maintained far more often than a terminal's config is, so that cost lands on more people here than it does in lib/wtsettings.ps1. Preserving comments would mean editing the text in place rather than reparsing it, and this prototype does not. What it will NOT touch: - any colour customisation that is not one of the terminal IDs a style sets, so an editor or statusBar override the user made survives - any setting outside terminal.integrated.* - any terminal.integrated.* key a style has no opinion about A style with no font block leaves the font alone rather than resetting it to a default: "this style does not say" and "this style says default" are different, and only one of them is true. #> [CmdletBinding()] param( [Parameter(Mandatory)]$Settings, [Parameter(Mandatory)][string]$StyleDir ) $plan = Get-VSCodeStyleSetting -StyleDir $StyleDir if ($plan.Colors.Count -gt 0) { $custom = $null if ($Settings.PSObject.Properties['workbench.colorCustomizations']) { $custom = $Settings.'workbench.colorCustomizations' } if (-not $custom) { $custom = [pscustomobject]@{} } foreach ($id in $plan.Colors.Keys) { Set-JsonMember -Object $custom -Name $id -Value $plan.Colors[$id] } Set-JsonMember -Object $Settings -Name 'workbench.colorCustomizations' -Value $custom } foreach ($key in $plan.Settings.Keys) { Set-JsonMember -Object $Settings -Name $key -Value $plan.Settings[$key] } return $Settings } function Remove-StyleFromVSCodeSettings { <# .SYNOPSIS Take a style back out of a parsed settings.json object. .DESCRIPTION The other half of the merge, and written against the same plan so the two cannot describe different key sets. It removes a key only when the value still matches what the style put there. A user who changed terminal.ansiRed by hand after applying a style meant it, and reset is not entitled to that edit -- "remove what an apply put there, leave the user's own settings alone" is the rule the Windows Terminal reset already states in those words. `workbench.colorCustomizations` is deleted outright if emptying it leaves nothing, rather than leaving `{}` behind: an orphan object in a config we were asked to clean out is the same defect as an orphan colour scheme. #> [CmdletBinding()] param( [Parameter(Mandatory)]$Settings, [Parameter(Mandatory)][string]$StyleDir ) $plan = Get-VSCodeStyleSetting -StyleDir $StyleDir if ($Settings.PSObject.Properties['workbench.colorCustomizations']) { $custom = $Settings.'workbench.colorCustomizations' if ($custom) { foreach ($id in $plan.Colors.Keys) { $prop = $custom.PSObject.Properties[$id] if ($prop -and $prop.Value -eq $plan.Colors[$id]) { $custom.PSObject.Properties.Remove($id) } } if (@($custom.PSObject.Properties).Count -eq 0) { $Settings.PSObject.Properties.Remove('workbench.colorCustomizations') } } } foreach ($key in $plan.Settings.Keys) { $prop = $Settings.PSObject.Properties[$key] if ($prop -and $prop.Value -eq $plan.Settings[$key]) { $Settings.PSObject.Properties.Remove($key) } } return $Settings } |