lib/deletestyle.ps1
|
# deletestyle.ps1 -- `tstyles delete`, and the bundled-vs-yours question. # # Dot-sourced by tstyles.ps1, so everything here shares its $script: scope. # # The tuner makes creating a style a two-keystroke affair, and every result # lands in the user styles dir as a full style that lists, tab-completes and # shows in the picker. Until this file there was no way to remove one: the only # path was `tstyles uninstall -DeleteData`, which destroys the whole data root. # # The hard part is not the removal, it is ANSWERING WHO OWNS A STYLE, and the # obvious answer is wrong. See Get-StyleOrigin. function Get-StyleTrashRoot { # Where a deleted style is kept. Same shape as Get-StyleCacheDir so there is # one place that knows the path. Dot-prefixed like .tune-preview, the # existing precedent for a private sibling of styles/. Join-Path $script:TStylesDataRoot '.deleted' } function Test-StylesRootsAreOne { <# .SYNOPSIS Are the bundled and user style directories the same directory? .DESCRIPTION On a BOOTSTRAP install they are. install.ps1 sets its install dir to Get-TStylesDataRoot, so $script:TStylesModuleRoot and $script:TStylesDataRoot are the same path and styles/ holds the sixteen bundled styles beside the user's own. That is the common case for this project and it is what makes the obvious ownership test wrong. NOT Get-TerminalStylesInstallKind: that compares against a freshly computed Get-TStylesDataRoot rather than the $script: variables, so it answers about the machine instead of about the roots actually in use, and reports 'PSResourceGet' in any sandbox where the two script roots are identical. #> [CmdletBinding()] param( [string]$ModuleStylesRoot = (Join-Path $script:TStylesModuleRoot 'styles'), [string]$DataStylesRoot = (Join-Path $script:TStylesDataRoot 'styles') ) Test-SameStyleDirectory -A $ModuleStylesRoot -B $DataStylesRoot } function Get-InstalledStyleClaim { <# .SYNOPSIS The style names the installer says it placed, or $null when it cannot say. .DESCRIPTION .installed-files carries one `styles/<name>` line per bundled style, which on a bootstrap install is the ONLY record of which styles came from the install and which the user made. Get-UninstallPlan (lib/update.ps1) already reads it for exactly this purpose, and already treats a style carrying tune.json as the user's regardless of what the manifest claims. $null -- not an empty list -- when the file is absent, unreadable, or contains no styles/ lines at all. The distinction is load-bearing: an empty CLAIM would mean "the installer placed no styles", so every bundled style would read as the user's and be offered for deletion. "Cannot say" must produce 'unknown', which is refused. #> [CmdletBinding()] param([string]$Path = (Join-Path $script:TStylesDataRoot '.installed-files')) if (-not (Test-Path -LiteralPath $Path)) { return $null } try { $lines = [System.IO.File]::ReadAllLines($Path, [System.Text.UTF8Encoding]::new($false)) } catch { return $null } $names = @(foreach ($l in $lines) { $t = "$l".Trim().TrimEnd('/', '\') if ($t -match '^styles[\\/]([^\\/]+)$') { $Matches[1] } }) if ($names.Count -eq 0) { return $null } return $names } function Get-StyleOrigin { <# .SYNOPSIS 'bundled' | 'shadow' | 'yours' | 'unknown' for one style. .DESCRIPTION The obvious test -- "is its FullName under the data root" -- is WRONG, and wrong in the most common layout. On a bootstrap install the module root IS the data root, so every bundled style's path is under the data root too: a path-prefix test reports all sixteen as the user's, which would badge them as yours and offer to delete them. So the question is answered differently per layout: SPLIT roots (PSGallery install: module dir separate from data dir) the directory tells the truth. A style resolved out of the user root is yours; one resolved out of the module root is bundled; a user style whose name also exists bundled is a SHADOW -- deleting it reveals the original rather than removing the name. The manifest is still consulted first, because a coexisting bootstrap install puts SHIPPED styles in the data root and the path alone would call all sixteen of them the user's. ONE root (bootstrap) the directory says nothing, so ask the install manifest, which lists what it placed. A style it does not claim is yours. A style it does claim is bundled -- UNLESS it carries tune.json, which is how Get-UninstallPlan already decides a shipped style has become the user's (an Overwrite save writes a tuned style under a bundled name). With no usable manifest there is no evidence either way, and the answer is 'unknown': never claim ownership the tool cannot prove, and never delete on a guess. -Claim and -RootsAreOne are seams: tests drive them directly, and Show-StyleList reads the manifest once for the whole listing rather than once per row. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$StyleDir, $Claim = '__unset__', $RootsAreOne = $null ) if ($Claim -is [string] -and $Claim -eq '__unset__') { $Claim = Get-InstalledStyleClaim } if ($null -eq $RootsAreOne) { $RootsAreOne = Test-StylesRootsAreOne } $hasTune = Test-Path -LiteralPath (Join-Path $StyleDir 'tune.json') if (-not $RootsAreOne) { $userRoot = Join-Path $script:TStylesDataRoot 'styles' $inUser = $false try { $sep = [System.IO.Path]::DirectorySeparatorChar $full = [System.IO.Path]::GetFullPath($StyleDir).TrimEnd($sep) $root = [System.IO.Path]::GetFullPath($userRoot).TrimEnd($sep) $inUser = $full.StartsWith($root + $sep, [System.StringComparison]::Ordinal) } catch { } if (-not $inUser) { return 'bundled' } # Sitting in the user root is not proof the USER put it there, and the # manifest in that same directory already knows which of us did. # # A bootstrap install writes its whole tree -- styles/ included -- into # what is also the data root, and README documents that a bootstrap and # a PSGallery install can coexist. When they do, the module root is the # versioned PSGallery directory and every SHIPPED style resolves out of # the data root instead, so all sixteen looked exactly like styles the # user had made: `tstyles list` badged them 'yours (shadows bundled)' # and `tstyles delete eva` offered to move eva to the trash, saying # 'your style'. `tstyles uninstall` on the PSGallery side leaves that # state behind, so it is reachable without ever installing twice on # purpose. # # A tuned copy is still the user's: an Overwrite save writes tune.json # under a bundled name, and 'shadow' already says "yours, shadowing # bundled". Only an UNtuned style the installer admits placing is # reclassified. With no manifest $Claim is $null, not an empty list, so # an ordinary PSGallery install -- where the data root holds only what # the user made -- keeps deciding by path exactly as before. if (-not $hasTune -and $null -ne $Claim -and ($Claim -contains $Name)) { return 'bundled' } $bundledTwin = Join-Path (Join-Path $script:TStylesModuleRoot 'styles') $Name if (Test-Path -LiteralPath (Join-Path $bundledTwin 'scheme.json')) { return 'shadow' } return 'yours' } # One root: the path cannot distinguish anything. if ($hasTune) { return 'yours' } if ($null -eq $Claim) { return 'unknown' } if ($Claim -contains $Name) { return 'bundled' } return 'yours' } function Get-StyleTuneChild { <# .SYNOPSIS Styles whose tune.json names $StyleDir as their base. .DESCRIPTION Their recorded brightness/saturation are DELTAS against this style. If it goes and nothing else provides the name, those numbers are not written down anywhere else -- so the user has to be told before confirming, by name and by value. HasFingerprint matters for the wording: a child with no baseFingerprint gets no "the base changed" notice on its next tune, so its adjustments change meaning with nothing on screen to explain it. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$StyleDir) $out = @() foreach ($s in (Get-AvailableStyles)) { if (Test-SameStyleDirectory -A $s.FullName -B $StyleDir) { continue } $tunePath = Join-Path $s.FullName 'tune.json' if (-not (Test-Path -LiteralPath $tunePath)) { continue } try { $t = [System.IO.File]::ReadAllText($tunePath, [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json } catch { continue } if (-not $t.base) { continue } $baseDir = Get-StyleDir -StyleName ([string]$t.base) if (-not $baseDir) { continue } if (-not (Test-SameStyleDirectory -A $baseDir -B $StyleDir)) { continue } $out += [pscustomobject]@{ Name = $s.Name Brightness = $(if ($t.PSObject.Properties.Match('brightness').Count) { [int]$t.brightness } else { 0 }) Saturation = $(if ($t.PSObject.Properties.Match('saturation').Count) { [int]$t.saturation } else { 0 }) HasFingerprint = [bool]($t.PSObject.Properties.Match('baseFingerprint').Count -and $t.baseFingerprint) } } return @($out) } function Test-PathIsStyleDirChild { <# .SYNOPSIS Is $Path a direct child of the user styles directory? .DESCRIPTION Proof before a move, not trust in how the path was built. Same reasoning as the tuner's scratch-directory guard: `tstyles tune ../styles/eva` once deleted a real style because a composed path was assumed to be inside the directory it was composed from. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [string]$Root = (Join-Path $script:TStylesDataRoot 'styles') ) try { $sep = [System.IO.Path]::DirectorySeparatorChar $full = [System.IO.Path]::GetFullPath($Path).TrimEnd($sep) $root = [System.IO.Path]::GetFullPath($Root).TrimEnd($sep) if (-not $full.StartsWith($root + $sep, [System.StringComparison]::Ordinal)) { return $false } # A direct child, not something nested deeper. return ([System.IO.Path]::GetDirectoryName($full).TrimEnd($sep) -eq $root) } catch { return $false } } function Get-StyleDeletePlan { <# .SYNOPSIS Everything the confirmation needs, decided before anything is touched. .DESCRIPTION Pure: reads the disk, writes nothing. That makes the whole decision -- what is about to happen, what survives, what does not -- unit-testable without a single mutation, which is the part of a destructive command worth testing. #> [CmdletBinding()] param([AllowEmptyString()][AllowNull()][string]$Name, $Claim = '__unset__', $RootsAreOne = $null) $plan = [pscustomobject]@{ Ok = $false; Reason = $null; Name = $Name; Dir = $null; Origin = $null IsLink = $false; RevealDir = $null; WasActive = $false Children = @(); KeptCache = $null; KeptProfile = $null TrashPath = $null; Consequence = $null } # Before Get-StyleDir: its parameter is a Mandatory [string], so an empty # name throws a raw binding error rather than returning $null. if ([string]::IsNullOrWhiteSpace($Name)) { $plan.Reason = 'noname'; return $plan } $dir = Get-StyleDir -StyleName $Name if (-not $dir) { $plan.Reason = 'notfound'; return $plan } $plan.Dir = $dir if ($Claim -is [string] -and $Claim -eq '__unset__') { $Claim = Get-InstalledStyleClaim } if ($null -eq $RootsAreOne) { $RootsAreOne = Test-StylesRootsAreOne } $plan.Origin = Get-StyleOrigin -Name $Name -StyleDir $dir -Claim $Claim -RootsAreOne $RootsAreOne if ($plan.Origin -eq 'bundled') { $plan.Reason = 'bundled'; return $plan } if ($plan.Origin -eq 'unknown') { $plan.Reason = 'unknown'; return $plan } if (-not (Test-PathIsStyleDirChild -Path $dir)) { $plan.Reason = 'outside'; return $plan } try { $item = Get-Item -LiteralPath $dir -Force -ErrorAction Stop $plan.IsLink = [bool]($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) } catch { } # Shadow: the name survives, revealing the bundled original. if ($plan.Origin -eq 'shadow') { $plan.RevealDir = Join-Path (Join-Path $script:TStylesModuleRoot 'styles') $Name } elseif ($RootsAreOne -and ($Claim -ne $null) -and ($Claim -contains $Name)) { # One root, manifest claims the name, but tune.json made it the user's: # removing it leaves nothing behind, and `tstyles update` will restore # the shipped copy under that name. $plan.RevealDir = $null } # Captured BEFORE the move: Get-CurrentStyleName answers differently # afterwards, and in opposite directions depending on whether the name # survives. try { $plan.WasActive = ((Get-CurrentStyleName) -eq $Name) } catch { } $plan.Children = @(Get-StyleTuneChild -StyleDir $dir) $cache = Get-StyleCacheDir -StyleName $Name if (Test-Path -LiteralPath $cache) { $plan.KeptCache = $cache } $prof = Join-Path (Join-Path $script:TStylesDataRoot 'profiles') "$Name.terminal" if (Test-Path -LiteralPath $prof) { $plan.KeptProfile = $prof } $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $plan.TrashPath = Join-Path (Get-StyleTrashRoot) "$Name-$stamp" $plan.Consequence = if ($plan.RevealDir) { "moves your '$Name' aside; the name reverts to the bundled style" } else { "moves '$Name' aside; nothing else provides that name" } $plan.Ok = $true return $plan } function Show-StyleDeletePlan { # Everything that is about to happen, itemised, before the question is # asked. Colours follow Invoke-TerminalStylesUninstall: Yellow for actions, # RED only for what does not come back, Gray for what is kept. [CmdletBinding()] param([Parameter(Mandatory)]$Plan) Write-Host "" Write-Host "This will delete your style '$($Plan.Name)':" -ForegroundColor Yellow Write-Host " - MOVE $($Plan.Dir)" -ForegroundColor Yellow Write-Host " to $($Plan.TrashPath)" -ForegroundColor Yellow if ($Plan.RevealDir) { Write-Host " - The name '$($Plan.Name)' STAYS: it reverts to the bundled '$($Plan.Name)'" -ForegroundColor Gray } else { Write-Host " - The name '$($Plan.Name)' GOES: nothing else provides it" -ForegroundColor Yellow } if ($Plan.WasActive) { if ($Plan.RevealDir) { Write-Host " - RE-APPLY the bundled '$($Plan.Name)' now, because yours is the active style" -ForegroundColor Yellow } else { Write-Host " - RESET the terminal to its unstyled default, because '$($Plan.Name)' is active" -ForegroundColor Yellow } } foreach ($c in $Plan.Children) { if ($Plan.RevealDir) { Write-Host (" - '{0}' was tuned from this style and re-seeds from the bundled one: same brightness/saturation, different colours" -f $c.Name) -ForegroundColor Gray if (-not $c.HasFingerprint) { Write-Host (" it records no base fingerprint, so its next tune will not mention the change") -ForegroundColor DarkGray } } else { Write-Host (" - '{0}' loses the brightness {1:+#;-#;0} and saturation {2:+#;-#;0} it was tuned by" -f $c.Name, $c.Brightness, $c.Saturation) -ForegroundColor Red Write-Host (" nothing else records those values") -ForegroundColor DarkGray } } if ($Plan.KeptCache) { Write-Host " - KEEP $($Plan.KeptCache)" -ForegroundColor Gray } if ($Plan.KeptProfile) { Write-Host " - KEEP $($Plan.KeptProfile)" -ForegroundColor Gray } if ($Plan.IsLink) { Write-Host " - This style is a symlink; only the link is moved, its target is untouched" -ForegroundColor Gray } Write-Host " - Nothing is erased: move the folder back to undo." -ForegroundColor Gray Write-Host "" } function Move-StyleDirectoryToTrash { <# .SYNOPSIS Move the style aside. Never a recursive delete of a live style directory. .DESCRIPTION A rename within the data root, not Remove-Item -Recurse, and the choice carries three separate safety properties: it is reversible; it cannot leave a half-removed directory (removing scheme.json first makes a style invisible to BOTH Get-AvailableStyles and Get-StyleDir while its other files sit on disk); and it moves a symlinked style dir as a link rather than descending into the target, which Windows PowerShell 5.1's recursive delete is known to do. Old trash is swept here rather than by a separate command, so the store cannot grow without bound -- the very complaint this feature exists to fix. #> [CmdletBinding()] param([Parameter(Mandatory)]$Plan, [int]$KeepDays = 7) $trashRoot = Get-StyleTrashRoot # Sweep first, and only inside the trash root. if (Test-Path -LiteralPath $trashRoot) { $cutoff = (Get-Date).AddDays(-$KeepDays) foreach ($old in @(Get-ChildItem -LiteralPath $trashRoot -Directory -Force -ErrorAction SilentlyContinue)) { if ($old.LastWriteTime -ge $cutoff) { continue } if (-not (Test-PathIsStyleDirChild -Path $old.FullName -Root $trashRoot)) { continue } try { if ($old.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { [System.IO.Directory]::Delete($old.FullName, $false) } else { Remove-Item -LiteralPath $old.FullName -Recurse -Force -ErrorAction Stop } } catch { } } } # Re-prove containment at the moment of the move, not just when the plan # was built. if (-not (Test-PathIsStyleDirChild -Path $Plan.Dir)) { throw "Refusing to move '$($Plan.Dir)': it is not a style directory under the data root." } if (-not (Test-Path -LiteralPath $trashRoot)) { New-Item -ItemType Directory -Path $trashRoot -Force | Out-Null } Move-Item -LiteralPath $Plan.Dir -Destination $Plan.TrashPath -ErrorAction Stop if ((Test-Path -LiteralPath $Plan.Dir) -or -not (Test-Path -LiteralPath $Plan.TrashPath)) { throw "Move did not complete: '$($Plan.Dir)' -> '$($Plan.TrashPath)'. Nothing else was changed." } } function Show-DeletableStyleList { # `tstyles delete` with no name. Shows only what the command can act on. [CmdletBinding()] param() $claim = Get-InstalledStyleClaim $one = Test-StylesRootsAreOne $yours = @(foreach ($s in (Get-AvailableStyles)) { $o = Get-StyleOrigin -Name $s.Name -StyleDir $s.FullName -Claim $claim -RootsAreOne $one if ($o -eq 'yours' -or $o -eq 'shadow') { [pscustomobject]@{ Name = $s.Name; Origin = $o } } }) Write-Host "" if (-not $yours) { Write-Host " You have no styles of your own to delete." -ForegroundColor Gray Write-Host " Make one with: tstyles tune" -ForegroundColor DarkGray Write-Host "" return } Write-Host "Your styles:" -ForegroundColor Cyan Write-Host "" foreach ($y in $yours) { if ($y.Origin -eq 'shadow') { Write-Host (" {0,-16} shadows the bundled '{0}'" -f $y.Name) } else { Write-Host (" {0,-16}" -f $y.Name) } } Write-Host "" Write-Host " Delete one with: tstyles delete <name>" -ForegroundColor DarkGray Write-Host " Bundled styles are refused. A deleted folder is kept for 7 days under" -ForegroundColor DarkGray Write-Host " $(Get-StyleTrashRoot)" -ForegroundColor DarkGray Write-Host "" } function Invoke-TerminalStyleDelete { # `tstyles delete [name]`. [CmdletBinding()] param([AllowEmptyString()][AllowNull()][string]$Name, [string]$Target, [switch]$Yes) $plan = Get-StyleDeletePlan -Name $Name if (-not $plan.Ok) { switch ($plan.Reason) { 'noname' { Show-DeletableStyleList; return } 'notfound' { Write-Host "Unknown style: '$Name'" -ForegroundColor Yellow Write-Host " tstyles delete lists the styles you can delete" -ForegroundColor DarkGray return } 'bundled' { Write-Host "'$Name' is a bundled style -- it came with TerminalStyles, so there is nothing of yours to delete." -ForegroundColor Yellow Write-Host " To stop using it: tstyles reset" -ForegroundColor DarkGray return } 'unknown' { # No manifest and no tune.json: the tool genuinely cannot tell # whether this style is the user's. Refusing is the only honest # answer -- deleting a bundled style here is unrecoverable # short of a reinstall. Write-Host "Cannot tell whether '$Name' is yours or came with TerminalStyles." -ForegroundColor Yellow Write-Host " The install record ($(Join-Path $script:TStylesDataRoot '.installed-files')) is missing or unreadable," -ForegroundColor DarkGray Write-Host " and '$Name' carries no tune.json. Refusing rather than guessing." -ForegroundColor DarkGray Write-Host " Move the folder by hand if you are sure: $($plan.Dir)" -ForegroundColor DarkGray return } 'outside' { Write-Host "'$Name' does not live in your styles directory -- refusing to touch it." -ForegroundColor Yellow return } default { Write-Host "Cannot delete '$Name'." -ForegroundColor Yellow; return } } } Show-StyleDeletePlan -Plan $plan if (-not (Confirm-Action -Question "Delete '$($plan.Name)'? [y/N]" -Consequence $plan.Consequence -Yes:$Yes)) { Write-Host " Cancelled." -ForegroundColor Gray return } try { Move-StyleDirectoryToTrash -Plan $plan } catch { Write-Host "Could not delete '$($plan.Name)': $_" -ForegroundColor Red return } Write-Host "" Write-Host " Deleted $($plan.Name)." -ForegroundColor Green Write-Host " Kept for 7 days at $($plan.TrashPath)" -ForegroundColor Gray if ($plan.KeptCache) { Write-Host " Kept $($plan.KeptCache)" -ForegroundColor DarkGray } if ($plan.KeptProfile) { Write-Host " Kept $($plan.KeptProfile)" -ForegroundColor DarkGray } # Reconciliation LAST, and only once the move is verified, so a failed move # never repaints the terminal. if ($plan.WasActive) { if ($plan.RevealDir) { Apply-StyleDirect -StyleName $plan.Name -Target $Target } else { Reset-StyleDirect -Target $Target } } } |