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. # # HOW IT WRITES. Through lib/jsonctext.ps1, which splices the original text # rather than reparsing and reserialising it. The round trip this replaced # dropped every comment and all formatting in a file people hand-annotate far # more than they do a terminal's config. Nothing here touches a file; the # functions take text and give text back, which is what lets the tests drive # every hard case as a string. # # HOW IT UNDOES. Off a RECORD of what the apply wrote, not off the style. # For each key the record keeps the literal written and the literal that was # there before. Two defects follow from not having one: # # - "this key holds our value" is not "this key is ours". Somebody who set # terminal.integrated.cursorStyle to block years ago collides with # koholint, whose filledBox maps to block; value-matching deletes their # setting and calls it cleanup. # - a reset that rebuilds the key list from the style cannot run once the # style is gone. `tstyles delete koholint` then reset used to strand # twenty colour keys with nothing able to name them. # # UNITS. Windows Terminal's font.size is POINTS, VS Code's # terminal.integrated.fontSize is PIXELS. See ConvertTo-VSCodePixelSize. 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-VSCodeVariant { <# .SYNOPSIS Which VS Code-family editor this terminal belongs to, or $null if unsure. .DESCRIPTION Pure. Cursor, Windsurf and VSCodium all set TERM_PROGRAM=vscode in their integrated terminals, so Get-TerminalKind's 'VSCode' says WHICH FAMILY and cannot say which application. Defaulting that to stable VS Code writes a Cursor user's style into ~/Library/Application Support/Code/User/settings.json -- creating that file for an editor they may not have installed -- while the terminal they are looking at does not change and the command reports success. The same bug has been filed against other tools that guessed here (anthropics/claude-code#33454). The evidence is the application path VS Code's own git integration exports: VSCODE_GIT_ASKPASS_NODE, or GIT_ASKPASS. A fork ships that from its own install directory, so the path carries the application name. Matching is by PATH SEGMENT, not substring, and an ambiguous answer is no answer: - each segment is compared whole (with a trailing .app or .exe removed) against the known names. A substring match would read /Users/cursor/... as Cursor for somebody whose username is cursor. The names include Windows' install folders, "Microsoft VS Code" and "Microsoft VS Code Insiders", where the askpass node is the editor's own .exe. - if the segments name exactly one variant, that is the answer. - if they name none, or more than one, the answer is $null. A caller that cannot tell which editor it is in must refuse, not guess: writing the wrong application's settings file and reporting success is worse than doing nothing, and the user has no reason to look for it. -AskpassNode / -GitAskpass are test seams; real callers omit them. #> [CmdletBinding()] param( [string]$AskpassNode = $env:VSCODE_GIT_ASKPASS_NODE, [string]$GitAskpass = $env:GIT_ASKPASS ) # <lowercased path segment> -> <the -Variant name Get-VSCodeSettingsPath takes> $known = @{ 'cursor' = 'Cursor' 'windsurf' = 'Windsurf' 'vscodium' = 'VSCodium' 'codium' = 'VSCodium' 'code - insiders' = 'Code - Insiders' 'visual studio code - insiders' = 'Code - Insiders' 'code-insiders' = 'Code - Insiders' 'visual studio code' = 'Code' 'microsoft vs code' = 'Code' 'microsoft vs code insiders' = 'Code - Insiders' 'code' = 'Code' } $seen = [System.Collections.Generic.List[string]]::new() foreach ($candidate in @($AskpassNode, $GitAskpass)) { if (-not $candidate) { continue } foreach ($segment in ($candidate -split '[\\/]')) { if (-not $segment) { continue } $name = $segment # -eq is case-insensitive, so Code.EXE and Cursor.App strip too. if ($name.Length -gt 4) { $ext = $name.Substring($name.Length - 4) if ($ext -eq '.app' -or $ext -eq '.exe') { $name = $name.Substring(0, $name.Length - 4) } } $name = $name.ToLowerInvariant() if ($known.ContainsKey($name)) { $variant = $known[$name] if (-not $seen.Contains($variant)) { $seen.Add($variant) } } } } if ($seen.Count -eq 1) { return $seen[0] } return $null } 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 ConvertTo-VSCodePixelSize { <# .SYNOPSIS A Windows Terminal font size (POINTS) as a VS Code font size (PIXELS). .DESCRIPTION Pure, and the reason it exists is that the two are not the same unit and nothing said so. Windows Terminal documents `font.size` as "the profile's font size in points"; VS Code documents `terminal.integrated.fontSize` as the size in pixels. Copying the number across writes a terminal about a quarter smaller than the style asks for -- 11pt is 14.7px, not 11px -- on every bundled style, which makes it the normal outcome rather than an edge case. 96 CSS pixels to the inch, 72 points to the inch, so the ratio is 4/3. Rounds AWAY FROM ZERO, not with [Math]::Round's default. That default is banker's rounding: [Math]::Round(14.5) is 14, not 15, which would quietly shrink every half-point size by one pixel. #> [CmdletBinding()] param([Parameter(Mandatory)][double]$PointSize) if ($PointSize -le 0) { return 0 } return [int][Math]::Round(($PointSize * 4.0 / 3.0), [System.MidpointRounding]::AwayFromZero) } function Get-VSCodeStylePlan { <# .SYNOPSIS Everything a style asks VS Code for, as a flat list of path/value entries. .DESCRIPTION Pure, and the ONE place that decides what a style means for VS Code. The apply reads it to write, and the apply's RECORD is what the reset reads -- so the two halves cannot drift into different opinions about which keys are involved. Each entry is @{ Path = <string[]>; Value = <object> }. Path is an array because VS Code keys contain dots: "terminal.integrated.fontSize" is one top-level key, and "terminal.background" is one key nested inside "workbench.colorCustomizations". Nothing here ever splits a string on '.'. A key the style has no opinion about is omitted rather than defaulted: "this style does not say" and "this style says the default" are different claims and only one of them is true. #> [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 { } } $entries = [System.Collections.Generic.List[object]]::new() $colors = Get-VSCodeTerminalColor -Scheme $scheme foreach ($id in $colors.Keys) { $entries.Add(@{ Path = @('workbench.colorCustomizations', $id); Value = $colors[$id] }) } if ($theme) { if ($theme.font -and $theme.font.face) { $entries.Add(@{ Path = @('terminal.integrated.fontFamily'); Value = $theme.font.face }) } if ($theme.font -and $theme.font.size) { $entries.Add(@{ Path = @('terminal.integrated.fontSize') Value = (ConvertTo-VSCodePixelSize -PointSize ([double]$theme.font.size)) }) } if ($theme.font -and $theme.font.weight) { $w = Get-VSCodeFontWeight -Weight $theme.font.weight if ($null -ne $w) { $entries.Add(@{ Path = @('terminal.integrated.fontWeight'); Value = $w }) } } $cursor = Get-VSCodeCursorStyle -CursorShape $theme.cursorShape if ($cursor) { $entries.Add(@{ Path = @('terminal.integrated.cursorStyle'); Value = $cursor }) } } return ,$entries.ToArray() } function Get-VSCodeColorOverride { <# .SYNOPSIS Theme-scoped colour blocks that would win over the colours we write. .DESCRIPTION Pure. workbench.colorCustomizations may hold THEME-SCOPED blocks as well as plain colour ids: "workbench.colorCustomizations": { "[Default Dark+]": { "terminal.background": "#000000" }, "terminal.background": "#182098" } When that theme is active the scoped entry is what takes effect, so a style written into the plain siblings is correct, saved, and invisible. VS Code has issues filed on exactly that confusion (microsoft/vscode#61566, and #303246 for terminal.background specifically). This is the capability rule pointed one level down. "Capabilities are promises, not possibilities" is about claiming a writer this module does not have; the same false promise happens when the writer exists, succeeds, and is overruled -- the user is told a style was applied and their terminal does not change. Detecting it is what lets the caller say so instead. Returns one record per scoped block that collides: @{ Scope = '[Default Dark+]'; Ids = @('terminal.background', ...) } Empty when nothing collides, which is the normal case. It does NOT look at workspace settings. A .vscode/settings.json overrides the user file and is not visible from this text at all; that belongs to whatever eventually reads files, and is called out rather than pretended about. #> [CmdletBinding()] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Ids ) $out = [System.Collections.Generic.List[object]]::new() $tokens = Get-JsoncToken -Text $Text $found = Resolve-JsoncPath -Text $Text -Tokens $tokens -Path @('workbench.colorCustomizations') if (-not $found.Member -or $found.Member.ValueOpen -lt 0) { return ,$out.ToArray() } $members = Get-JsoncObjectMember -Text $Text -Tokens $tokens -OpenIndex $found.Member.ValueOpen foreach ($m in $members) { # A scope is a key in brackets: "[Monokai]", "[Abyss][Red]", "[Monokai*]". if (-not $m.Name -or -not $m.Name.StartsWith('[')) { continue } if ($m.ValueOpen -lt 0 -or $tokens[$m.ValueOpen].Kind -ne 'BraceOpen') { continue } $inner = Get-JsoncObjectMember -Text $Text -Tokens $tokens -OpenIndex $m.ValueOpen $hits = [System.Collections.Generic.List[string]]::new() foreach ($i in $inner) { if ($Ids -contains $i.Name) { $hits.Add($i.Name) } } if ($hits.Count -gt 0) { $out.Add(@{ Scope = $m.Name; Ids = $hits.ToArray() }) } } return ,$out.ToArray() } function Invoke-VSCodeStyleApply { <# .SYNOPSIS Apply a style to settings.json TEXT. Returns @{ Text; Record; Changed; Status }. .DESCRIPTION Pure: text in, text out, no file touched. Every write goes through lib/jsonctext.ps1, so comments and formatting survive. The RECORD is the point. For every key it writes, it keeps the literal it wrote and the literal that was there BEFORE -- or $null when the key was absent. That record, not the style, is what a later reset reads. Two things follow from that, and both were defects without it: - Reset can tell OUR key from a key that merely holds our value. A user who set "terminal.integrated.cursorStyle": "block" years ago collides with koholint, whose filledBox maps to block; value-matching deletes their setting and calls it cleanup. - Reset does not need the style any more. `tstyles delete koholint` followed by a reset used to leave twenty colour keys stranded with no command able to name them. #> [CmdletBinding()] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [Parameter(Mandatory)][string]$StyleDir, [string]$StyleName, [AllowNull()]$PreviousRecord ) $current = $Text $carriedKept = @() # Undo the style that is already on before applying this one. Without it, # switching styles leaves the previous one's keys behind AND poisons this # apply's record: each key's "what was there before" would be the PREVIOUS # STYLE's value, not the user's, so a later reset restores a value the user # never had and no command can remove it. # # Measured, not theorised. eva writes fontWeight 600 and koholint writes # "normal"; apply eva, apply koholint, reset koholint, and the file is left # holding 600 -- eva's, on a machine whose owner never set a font weight. # # Switching styles is the common case. The prompt for this work said "runs # a style, changes their mind", and in practice changing your mind means # another style, not a reset. if ($PreviousRecord) { $undo = Invoke-VSCodeStyleReset -Text $current -Record $PreviousRecord $current = $undo.Text $carriedKept = $undo.Kept } $plan = Get-VSCodeStylePlan -StyleDir $StyleDir $wrote = [System.Collections.Generic.List[object]]::new() $created = [System.Collections.Generic.List[object]]::new() foreach ($entry in $plan) { # A nested key needs its parent object to exist. Creating it is # recorded, so a reset that empties it again can take it away rather # than leaving an empty block behind. if ($entry.Path.Count -gt 1) { $parentPath = $entry.Path[0..($entry.Path.Count - 2)] if ($null -eq (Get-JsoncValueLiteral -Text $current -Path $parentPath)) { $mk = Set-JsoncLiteral -Text $current -Path $parentPath -Literal '{}' if (-not $mk.Changed) { return @{ Text = $Text; Record = $null; Changed = $false; Status = $mk.Status } } $current = $mk.Text $created.Add($parentPath) } } $had = Get-JsoncValueLiteral -Text $current -Path $entry.Path $literal = ConvertTo-JsoncLiteral -Value $entry.Value $r = Set-JsoncLiteral -Text $current -Path $entry.Path -Literal $literal if ($r.Status -eq 'rootnotobject' -or $r.Status -eq 'parentnotobject' -or $r.Status -eq 'parentmissing' -or $r.Status -eq 'unterminated') { return @{ Text = $Text; Record = $null; Changed = $false; Status = $r.Status } } $current = $r.Text $wrote.Add(@{ Path = $entry.Path; Literal = $literal; Had = $had }) } $record = @{ Style = $StyleName Wrote = $wrote.ToArray() CreatedParents = $created.ToArray() } return @{ Text = $current Record = $record Changed = ($current -cne $Text) Status = 'applied' # What the undo of the previous style declined to touch, because the # user had changed it since. Carried out so a caller can say so rather # than leaving it to be discovered. Kept = $carriedKept } } function Invoke-VSCodeStyleReset { <# .SYNOPSIS Undo an apply, using its record. Returns @{ Text; Restored; Removed; Kept; Status }. .DESCRIPTION Pure. Walks the record and, for each key we wrote: - the value is no longer the literal we wrote -> KEPT, untouched. The user changed it after we did, and that edit is theirs. It is reported, not silently skipped: "I left it because it looks like yours" must not collapse into "there was nothing to do", which CLAUDE.md names as a defect this repo has already shipped once. - there was a value before we wrote -> RESTORED to it, exactly as it was, byte for byte. Not deleted: the user had a setting and we overwrote it, so putting it back is the undo. Deleting it would be a second, uninvited change. - there was no key before -> REMOVED. Comparison is ORDINAL (-cne). '#F5F5F5' is the same colour as '#f5f5f5' and is not the same text; if the bytes differ, something other than us wrote them, and the safe reading of that is "leave it alone". #> [CmdletBinding()] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [Parameter(Mandatory)][AllowNull()]$Record ) if (-not $Record -or -not $Record.Wrote) { return @{ Text = $Text; Restored = @(); Removed = @(); Kept = @(); Status = 'norecord' } } $current = $Text $restored = [System.Collections.Generic.List[string]]::new() $removed = [System.Collections.Generic.List[string]]::new() $kept = [System.Collections.Generic.List[string]]::new() foreach ($w in $Record.Wrote) { $name = ($w.Path -join '/') $now = Get-JsoncValueLiteral -Text $current -Path $w.Path if ($null -eq $now) { continue } # already gone; nothing to undo if ($now -cne $w.Literal) { $kept.Add($name); continue } if ($null -eq $w.Had) { $r = Remove-JsoncValue -Text $current -Path $w.Path if ($r.Changed) { $current = $r.Text; $removed.Add($name) } } else { $r = Set-JsoncLiteral -Text $current -Path $w.Path -Literal $w.Had if ($r.Changed) { $current = $r.Text; $restored.Add($name) } } } # A parent object this apply created, and which is empty again, goes too -- # an orphan {} left in a config we were asked to clean out is the same # defect as an orphan colour scheme in Windows Terminal's settings. foreach ($parentPath in @($Record.CreatedParents)) { if (-not $parentPath) { continue } $tokens = Get-JsoncToken -Text $current $found = Resolve-JsoncPath -Text $current -Tokens $tokens -Path $parentPath if (-not $found.Member -or $found.Member.ValueOpen -lt 0) { continue } # NOT @(...) around this call. Get-JsoncObjectMember returns ,$arr to # stop a one-member object unrolling into a bare hashtable -- and a # comma-wrapped return put back inside @() is an array of ONE, whatever # it holds. Wrapping it here reported an empty object as having one # member, so the emptied colour block was never taken away. $members = Get-JsoncObjectMember -Text $current -Tokens $tokens -OpenIndex $found.Member.ValueOpen if (@($members).Count -eq 0 -or $null -eq $members -or $members.Count -eq 0) { $r = Remove-JsoncValue -Text $current -Path $parentPath if ($r.Changed) { $current = $r.Text; $removed.Add(($parentPath -join '/')) } } } return @{ Text = $current Restored = $restored.ToArray() Removed = $removed.ToArray() Kept = $kept.ToArray() Status = 'reset' } } |