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' } # How long a deleted style is kept. ONE definition: Get-StyleTrashEntry decides # expiry from it, Get-StyleTrashSweepTarget filters that, and every message that # quotes a number interpolates it. A literal in a message is a second answer to # the same question, and the message is the half that goes stale -- "kept for 7 # days" outliving a changed window is this project's most-shipped defect class # in one line. $script:TStylesTrashKeepDays = 7 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 Test-PathIsUnderRoot { <# .SYNOPSIS Is $Path inside $Root -- at any depth? .DESCRIPTION The containment rule itself, in ONE place. It was written three times: as the front half of Test-PathIsStyleDirChild (the proof in front of the style delete), open-coded again inside Get-StyleOrigin's split-root branch to decide whether a style resolved out of the user root, and a third time in the tuner in front of the recursive delete of its scratch session. Two implementations of one rule diverge -- the whole of CHANGELOG is that shape -- and this one decides who owns a directory and whether it may be erased. The three had already drifted: a path GetFullPath refuses left the other two answering $false through their own catch, while this one's caller threw a raw binding error before reaching any of that. Both properties are load-bearing and both come from the separator: GetFullPath normalises `<root>/../styles/eva` and `<root>/eva/..` before anything is compared, and requiring `<root><sep>` rather than `<root>` keeps a character-prefix SIBLING (`<root>X/eva`) out -- the same boundary bug the prompt halves had against $HOME. Returns $true/$false; never throws: it is a guard, and a guard that throws on the input it exists to reject is not a guard. An empty or null path is not inside anything. #> [CmdletBinding()] param( [Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string]$Root ) if ([string]::IsNullOrWhiteSpace($Path)) { return $false } if ([string]::IsNullOrWhiteSpace($Root)) { return $false } try { $sep = [System.IO.Path]::DirectorySeparatorChar $full = [System.IO.Path]::GetFullPath($Path).TrimEnd($sep) $root = [System.IO.Path]::GetFullPath($Root).TrimEnd($sep) return $full.StartsWith($root + $sep, [System.StringComparison]::Ordinal) } catch { return $false } } 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 Test-StyleDirectoryIsUsers says the user has made it theirs, which is how Get-UninstallPlan and install.ps1's styles merge already decide the same question (an Overwrite save writes a tuned style under a bundled name; a hand-dropped override writes no marker at all and is caught by the install's content record instead). 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, -RootsAreOne and -StyleHash 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, $StyleHash = '__unset__' ) if ($Claim -is [string] -and $Claim -eq '__unset__') { $Claim = Get-InstalledStyleClaim } if ($null -eq $RootsAreOne) { $RootsAreOne = Test-StylesRootsAreOne } if ($StyleHash -is [string] -and $StyleHash -eq '__unset__') { $StyleHash = Get-InstalledStyleHash -DataDir $script:TStylesDataRoot } # The third reader of one rule. It used to be a bare tune.json test here, a # second one in Get-UninstallPlan and a third in install.ps1's styles merge # -- all three agreeing about the tuner's Overwrite save and all three # silent about the hand-dropped override README documents, which is why # `tstyles list` badged it 'bundled' and `tstyles delete` refused it while # `tstyles update` quietly reverted it. $isUsers = Test-StyleDirectoryIsUsers -StyleDir $StyleDir -Recorded $StyleHash if (-not $RootsAreOne) { # Through the shared guard, not a second copy of it. This branch used # to open-code the same GetFullPath/TrimEnd/StartsWith computation that # Test-PathIsStyleDirChild makes in front of the delete, minus the # direct-child test -- two implementations of one containment rule, # one deciding ownership and one deciding whether a directory may be # moved to the trash. $userRoot = Join-Path $script:TStylesDataRoot 'styles' $inUser = Test-PathIsUnderRoot -Path $StyleDir -Root $userRoot 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 copy the user has made theirs is still theirs: an Overwrite save # writes tune.json under a bundled name, a hand-dropped override changes # the shipped content, and 'shadow' already says "yours, shadowing # bundled". Only a style the installer admits placing AND still # recognises 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 as before. if (-not $isUsers -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 ($isUsers) { 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. KeepsAdjustments is what the delete actually does to this child, decided here rather than guessed by the printer. It is false whenever the name goes -- the base stops resolving, so the deltas are dropped -- and when the name REVERTS to a bundled style it is the fingerprint question: Test-TuneBaseMoved against -RevealDir, the same comparison Resolve-TuneSeed will make the next time the child is tuned. 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, # Where the name will resolve after the delete, when it survives at all # (a shadow reverting to its bundled original). Empty means the name # goes with the style. [AllowEmptyString()][AllowNull()][string]$RevealDir ) $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 } $recordedFp = $(if ($t.PSObject.Properties.Match('baseFingerprint').Count) { [string]$t.baseFingerprint } else { '' }) $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]$recordedFp KeepsAdjustments = $(if ($RevealDir) { -not (Test-TuneBaseMoved -RecordedFingerprint $recordedFp -BaseDir $RevealDir) } else { $false }) } } 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. Returns $true/$false; never throws -- including for an empty or null path. #> [CmdletBinding()] param( # Empty and null are ALLOWED so they can be ANSWERED. Without these two # attributes the parameter binder threw # "Cannot bind argument to parameter 'Path' because it is an empty # string" before a line of the guard ran -- a raw binding error out of # the containment proof that stands in front of a Move-Item and a # recursive Remove-Item, where the only safe answer to "is '' a style # directory?" is $false. The two sibling guards this one is modelled on # (Test-StyleNameIsSingleSegment, Test-StyleNameValid) carry both # attributes and both promise never to throw; this one promised nothing # and was covered by no test at all. [Parameter(Mandatory)][AllowEmptyString()][AllowNull()][string]$Path, [string]$Root = (Join-Path $script:TStylesDataRoot 'styles') ) # The containment half is Test-PathIsUnderRoot's, so the rule lives in one # place; only the direct-child half is decided here. if (-not (Test-PathIsUnderRoot -Path $Path -Root $Root)) { return $false } try { $sep = [System.IO.Path]::DirectorySeparatorChar $full = [System.IO.Path]::GetFullPath($Path).TrimEnd($sep) $root = [System.IO.Path]::GetFullPath($Root).TrimEnd($sep) # 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; SweepTargets = @() } # 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 the user has made it theirs # -- a tuner Overwrite save, or the hand-dropped override README # documents. There is only ONE directory of that name on this layout, so # removing it leaves nothing behind; `tstyles update` restores 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 { } # After RevealDir on purpose: what a tuned child keeps depends on whether # the name survives the delete, and on what it resolves to if it does. $plan.Children = @(Get-StyleTuneChild -StyleDir $dir -RevealDir $plan.RevealDir) $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 } # What confirming this delete will also erase for good. $plan.SweepTargets = @(Get-StyleTrashSweepTarget) # InvariantCulture, because Get-StyleTrashTimestamp reads this back with it. # `Get-Date -Format` formats through CurrentCulture, and therefore through # that culture's DEFAULT CALENDAR: under ar-SA (UmAlQura) today stamps as # 14480326 and under fa-IR (Persian) as 14050617, both of which parse # cleanly as Gregorian years ~580 in the past -- so the folder was already # expired the instant it was created, and the next delete of any style # erased it having just printed "Kept for 7 days". th-TH (ThaiBuddhist) # fails the other way, stamping 2569 and making the trash unsweepable. # # The name still matched the reader's `-(\d{8})-(\d{6})$` pattern, so the # "no stamp -- fall back to LastWriteTime" escape hatch never engaged. Only # the reader had been pinned; this is the other half of that pair. $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss', [cultureinfo]::InvariantCulture) $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 Get-StyleTrashEntry { <# .SYNOPSIS What is in the trash right now: one row per deleted style, with how long it has left. .DESCRIPTION The trash was write-only. `tstyles delete` printed the folder it had just moved a style into, and after that scrollback line nothing in the tool could say what was in there, when it went, or how much of its window was left -- while the next delete of any style erased everything past it. A user who deleted a style, stopped deleting and came back a month later had a folder no command would list and no command would clear. Measured: `tstyles list`, `tstyles current` and every other subcommand said nothing about an expired entry sitting on disk, and `tstyles trash` did not exist to ask. Pure -- it reads the trash root and writes nothing -- so the listing can be printed without touching anything, exactly like Get-StyleTrashSweepTarget, which is now a FILTER over this: the window is decided in one place. No -KeepDays parameter, for the reason the sweep's own comment gives: a second place to set the window is a second answer to disagree with the first. $script:TStylesTrashKeepDays is that place. Newest deletion first, which is also the order `tstyles restore` resolves a repeated name in. #> [CmdletBinding()] param([datetime]$Now = (Get-Date)) $trashRoot = Get-StyleTrashRoot if (-not (Test-Path -LiteralPath $trashRoot)) { return @() } $rows = foreach ($d in @(Get-ChildItem -LiteralPath $trashRoot -Directory -Force -ErrorAction SilentlyContinue)) { # The same containment proof the sweep insists on. Something nested # deeper, or reached through a link, is not a folder this tool put here, # and neither listing it as recoverable nor offering to move it is honest. if (-not (Test-PathIsStyleDirChild -Path $d.FullName -Root $trashRoot)) { continue } $deletedAt = Get-StyleTrashTimestamp -Name $d.Name -Fallback $d.LastWriteTime $expiresAt = $deletedAt.AddDays($script:TStylesTrashKeepDays) # The name WITHOUT the stamp this tool appended. A restore lands at # styles/<StyleName>: the stamped name must never reach styles/, where # it would be a directory no listing shows under the name the user is # looking for and the next delete of the real name would not touch. # Same anchored pattern Get-StyleTrashTimestamp reads, so a style called # `solarized-2024` keeps its digits. $styleName = [regex]::Replace($d.Name, '-(\d{8})-(\d{6})$', '') if ([string]::IsNullOrWhiteSpace($styleName)) { $styleName = $d.Name } [pscustomobject]@{ Name = $d.Name StyleName = $styleName Path = $d.FullName DeletedAt = $deletedAt ExpiresAt = $expiresAt # -lt, matching the sweep's `$deletedAt -ge $cutoff` exactly: # deleted-at + window < now is the same comparison, moved. Expired = ($expiresAt -lt $Now) DaysLeft = [int][math]::Ceiling(($expiresAt - $Now).TotalDays) # A folder with no scheme.json restores to something Get-StyleDir # cannot resolve and no listing can show. Carried so the restore can # say that instead of reporting a style. IsStyle = (Test-Path -LiteralPath (Join-Path $d.FullName 'scheme.json')) # Whether the name is free to restore INTO. Asked of the user styles # dir, not of Get-StyleDir: a trashed style that shadowed a bundled # one restores over nothing, and refusing that would refuse the # shadow case the delete plan goes out of its way to explain. NameFree = -not (Test-Path -LiteralPath (Join-Path (Join-Path $script:TStylesDataRoot 'styles') $styleName)) # The DirectoryInfo itself, so the sweep keeps the object it needs # for the ReparsePoint test rather than re-enumerating the disk. Item = $d } } @($rows | Sort-Object -Property DeletedAt -Descending) } function Get-StyleTrashSweepTarget { <# .SYNOPSIS The trashed styles the next delete will remove for good. .DESCRIPTION The sweep decided this inline, so the only way to find out what a delete was about to erase was to let it erase them. The consent listing therefore could not mention it, and ended on "Nothing is erased: move the folder back to undo." -- while pressing y ran a recursive Remove-Item over every trashed style past the window. Measured: a style deleted weeks earlier and still recoverable was gone the moment the user confirmed the deletion of an unrelated one, having been told nothing would be. Bounded trash is the point of the feature; the listing not saying so was the defect. Pure -- it reads the trash root and writes nothing -- so the prompt can name the folders before the question is asked, and Move-StyleDirectoryToTrash sweeps exactly what was named because the rule lives here rather than in both. A filter over Get-StyleTrashEntry, which is the same argument one level up: `tstyles trash` prints the window to the user and this erases by it, so the two must not each own a copy of the comparison. -KeepDays is gone with the duplicate -- nothing ever passed it, and a per-call window is exactly the second answer this docstring argues against. Returns the DirectoryInfo rows, not the entries: Move-StyleDirectoryToTrash tests .Attributes for a ReparsePoint before deleting, and Show-StyleDeletePlan prints .Name. Projected with ForEach-Object, since member access on an empty array yields one $null. #> [CmdletBinding()] param([datetime]$Now = (Get-Date)) @(Get-StyleTrashEntry -Now $Now | Where-Object { $_.Expired } | ForEach-Object { $_.Item }) } 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 } } # On KeepsAdjustments, not on which branch of the delete this is. A child # keeps its deltas only where the base its fingerprint was taken from is # still what the name resolves to, so the reveal branch said "same # brightness/saturation" in Gray while the tuner dropped them to 0, for # every child saved since the fingerprint was introduced. foreach ($c in $Plan.Children) { if ($c.KeepsAdjustments) { 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 if ($Plan.RevealDir) { Write-Host (" they were measured against your '{0}', not the bundled one; nothing else records them, and its own colours do not change" -f $Plan.Name) -ForegroundColor DarkGray } else { 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 } # RED, because this is the one thing on the list that does not come back. foreach ($sw in $Plan.SweepTargets) { Write-Host (" - ERASE {0}, deleted over {1} days ago" -f $sw.Name, $script:TStylesTrashKeepDays) -ForegroundColor Red } if ($Plan.SweepTargets.Count -gt 0) { Write-Host " the trash keeps $($script:TStylesTrashKeepDays) days; this delete is what clears the rest" -ForegroundColor DarkGray Write-Host " tstyles trash lists them, with the days each has left" -ForegroundColor DarkGray } # Scoped to THIS style. Unqualified, it was the opposite of what the line # above describes. It named the undo as a file-manager move because that was # the only undo there was; `tstyles restore` is the one the tool performs. Write-Host " - Nothing of '$($Plan.Name)' is erased: tstyles restore $($Plan.Name) puts it back." -ForegroundColor Gray Write-Host "" } function Get-StyleTrashTimestamp { <# .SYNOPSIS When was this trashed style deleted? Read from its folder name, not its LastWriteTime. .DESCRIPTION The sweep used $old.LastWriteTime as the deletion time. Move-Item renames within the data root, and a rename does not touch the directory's LastWriteTime -- so that timestamp is when the STYLE was last edited, which is the one thing it cannot be. A style tuned once and left alone for a month arrived in the trash already a month stale, and the next `tstyles delete` of any style swept it on the spot, seconds after "Kept for 7 days at ..." said otherwise. The longer a style had gone untouched -- the better the reason to want it back -- the less of the promised window it actually got, and a style edited today got the full seven days. Get-StyleDeletePlan already stamps the trash folder name with the deletion time (`<name>-yyyyMMdd-HHmmss`), so the answer was on disk the whole time. Reading it from the name also means the sweep never has to WRITE to the trashed item to keep its own clock: stamping LastWriteTime would follow a symlinked style dir and modify the link's target, the exact thing Move-StyleDirectoryToTrash moves-as-a-link to avoid. Anything whose name does not carry a stamp -- trash from before this, or a folder some other hand put there -- falls back to LastWriteTime, which is the previous behaviour rather than a folder that can never be swept. #> [CmdletBinding()] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Name, [Parameter(Mandatory)][datetime]$Fallback ) # [regex]::Match rather than -match: the automatic $Matches is shared state, # and the suite has already been bitten once by code that wrote to it. $m = [regex]::Match($Name, '-(\d{8})-(\d{6})$') if ($m.Success) { $parsed = [datetime]::MinValue if ([datetime]::TryParseExact(($m.Groups[1].Value + $m.Groups[2].Value), 'yyyyMMddHHmmss', [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None, [ref]$parsed)) { return $parsed } } return $Fallback } 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. The reversible step runs FIRST and the irreversible one LAST. It was the other way round, so every ordinary way the move can fail -- the folder open in an editor on Windows, a permissions failure, the style removed by hand or by a second terminal between the plan and the keystroke -- still erased the expired trash for good, while the only thing printed was that the style could NOT be deleted. #> [CmdletBinding()] param([Parameter(Mandatory)]$Plan) $trashRoot = Get-StyleTrashRoot # 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)'." } # Only now, with the move verified: a delete that did not happen erases # nothing. # # Sweep the list the user was SHOWN, off the plan -- not a fresh one. # Calling Get-StyleTrashSweepTarget again here would re-read the clock AFTER # the prompt, and Confirm-Action blocks for as long as the user takes to # read it: a folder sitting at six days and twenty-three hours when the # listing was drawn crosses the window while they decide, and confirming # erases it having never named it. That is this same defect one step later, # and it is why the window is not a parameter here -- a second place to set # it is a second answer to disagree with the first. # # A plan carrying no SweepTargets sweeps nothing, which is the safe # direction to fail: the trash grows rather than losing something unnamed. foreach ($old in @($Plan.SweepTargets)) { 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 { } } } function Show-StyleTrashList { <# .SYNOPSIS `tstyles trash` -- what is in the trash, and what the next delete erases. .DESCRIPTION Read-only, on purpose. The erasure keeps the one consent screen it already has -- the delete plan, which names every expired folder in red before the question is asked -- and this command exists so that screen is not the only place the state is ever visible. It erases nothing itself. Colours follow Show-StyleDeletePlan: RED for what does not come back. #> [CmdletBinding()] param() $entries = @(Get-StyleTrashEntry) Write-Host "" if (-not $entries) { Write-Host " The trash is empty." -ForegroundColor Gray Write-Host " Deleting a style moves it here for $($script:TStylesTrashKeepDays) days: tstyles delete <name>" -ForegroundColor DarkGray Write-Host "" return } # A name can be in here twice -- deleted, made again, deleted again -- and # `tstyles restore <name>` puts back exactly one of them. Saying which is # the difference between a listing and a listing you can act on. $repeated = @($entries | Group-Object -Property StyleName | Where-Object { $_.Count -gt 1 } | ForEach-Object { $_.Name }) $newestSeen = @{} Write-Host "Deleted styles:" -ForegroundColor Cyan Write-Host "" foreach ($e in $entries) { $when = $e.DeletedAt.ToString('yyyy-MM-dd HH:mm', [cultureinfo]::InvariantCulture) if ($e.Expired) { Write-Host (" {0,-16} deleted {1} EXPIRED -- the next delete erases it" -f $e.StyleName, $when) -ForegroundColor Red } else { $left = if ($e.DaysLeft -eq 1) { '1 day left' } else { "$($e.DaysLeft) days left" } Write-Host (" {0,-16} deleted {1} {2}" -f $e.StyleName, $when, $left) } if ($repeated -contains $e.StyleName) { # Newest first, so the first row of a group is the one restore takes. if (-not $newestSeen.ContainsKey($e.StyleName)) { $newestSeen[$e.StyleName] = $true Write-Host (" folder {0} -- the copy 'tstyles restore {1}' puts back" -f $e.Name, $e.StyleName) -ForegroundColor DarkGray } else { Write-Host (" folder {0} -- an older copy of the same name; move it back by hand" -f $e.Name) -ForegroundColor DarkGray } } if (-not $e.NameFree) { Write-Host (" a style called '{0}' exists again, so restoring refuses until that one is renamed or deleted" -f $e.StyleName) -ForegroundColor DarkGray } if (-not $e.IsStyle) { Write-Host (" no scheme.json in it: restored, nothing would list it") -ForegroundColor DarkGray } } Write-Host "" Write-Host " Put one back with: tstyles restore <name>" -ForegroundColor DarkGray Write-Host " Kept for $($script:TStylesTrashKeepDays) days under $(Get-StyleTrashRoot)" -ForegroundColor DarkGray Write-Host " Nothing here is erased until the next tstyles delete, which lists what it takes." -ForegroundColor DarkGray Write-Host "" } function Invoke-TerminalStyleRestore { <# .SYNOPSIS `tstyles restore [name]` -- put a deleted style back. .DESCRIPTION The reverse of the move Move-StyleDirectoryToTrash makes, and deliberately only that: it moves the folder back and touches nothing else. The delete's other halves -- the reset or re-apply it ran, the cache and Terminal.app profile it KEPT -- are not undone here, because two of them were never changed and the third is the user's current terminal, which this command has no business repainting. Nothing is overwritten. If the name is in use again the restore REFUSES: the folder standing there is a style the user has since made, and no consent was given to spend it. That is what makes this safe to run without a prompt. Returns a STATUS, not a boolean -- 'restored' / 'noname' / 'none' / 'taken' / 'outside' / 'failed' -- the convention Unregister-ShellLoader established here after "it failed" and "there was nothing to do" became the same answer and the user was told the opposite of the truth. The timestamp is stripped unconditionally: see Get-StyleTrashEntry. #> [CmdletBinding()] param([AllowEmptyString()][AllowNull()][string]$Name) if ([string]::IsNullOrWhiteSpace($Name)) { # Same shape as `tstyles delete` with no name: show what the command can # act on rather than an error about the argument. Show-StyleTrashList return 'noname' } # The segment gate, not the stricter create-time one -- a trashed style is # whatever folder name the user chose, and this resolves an existing one. if (-not (Test-StyleNameIsSingleSegment -Name $Name)) { Write-Host "'$Name' is not a style name." -ForegroundColor Yellow return 'outside' } $entry = @(Get-StyleTrashEntry | Where-Object { $_.StyleName -eq $Name }) | Select-Object -First 1 if (-not $entry) { Write-Host "Nothing in the trash under '$Name'." -ForegroundColor Yellow Write-Host " tstyles trash lists what is in there" -ForegroundColor DarkGray return 'none' } $dest = Join-Path (Join-Path $script:TStylesDataRoot 'styles') $entry.StyleName if (Test-Path -LiteralPath $dest) { Write-Host "'$($entry.StyleName)' is taken -- something already lives at" -ForegroundColor Yellow Write-Host " $dest" -ForegroundColor Yellow Write-Host " Refusing rather than overwriting it. Rename or delete that one first;" -ForegroundColor DarkGray Write-Host " the trashed copy stays where it is:" -ForegroundColor DarkGray Write-Host " $($entry.Path)" -ForegroundColor DarkGray return 'taken' } # Containment re-proved at the moment of the move, on BOTH ends, rather than # trusted from the row the listing was built from. if (-not (Test-PathIsStyleDirChild -Path $entry.Path -Root (Get-StyleTrashRoot))) { Write-Host "Refusing to move '$($entry.Path)': it is not a trashed style directory." -ForegroundColor Yellow return 'outside' } if (-not (Test-PathIsStyleDirChild -Path $dest)) { Write-Host "Refusing to restore to '$dest': it is not a direct child of your styles directory." -ForegroundColor Yellow return 'outside' } try { $stylesRoot = Join-Path $script:TStylesDataRoot 'styles' if (-not (Test-Path -LiteralPath $stylesRoot)) { New-Item -ItemType Directory -Path $stylesRoot -Force | Out-Null } Move-Item -LiteralPath $entry.Path -Destination $dest -ErrorAction Stop if ((Test-Path -LiteralPath $entry.Path) -or -not (Test-Path -LiteralPath $dest)) { throw "Move did not complete: '$($entry.Path)' -> '$dest'." } } catch { Write-Host "Could not restore '$($entry.StyleName)': $_" -ForegroundColor Red return 'failed' } Write-Host "" Write-Host " Restored $($entry.StyleName)." -ForegroundColor Green Write-Host " to $dest" -ForegroundColor Gray if ($entry.IsStyle) { Write-Host " Apply it with: tstyles $($entry.StyleName)" -ForegroundColor DarkGray } else { # It is back, and saying "restored" and stopping would be a claim the # listing cannot keep. Write-Host " It carries no scheme.json, so tstyles list and the picker will not show it." -ForegroundColor Yellow } Write-Host "" return 'restored' } function Show-DeletableStyleList { # `tstyles delete` with no name. Shows only what the command can act on. [CmdletBinding()] param() $claim = Get-InstalledStyleClaim $one = Test-StylesRootsAreOne $hash = Get-InstalledStyleHash -DataDir $script:TStylesDataRoot $yours = @(foreach ($s in (Get-AvailableStyles)) { $o = Get-StyleOrigin -Name $s.Name -StyleDir $s.FullName -Claim $claim -RootsAreOne $one -StyleHash $hash 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 $($script:TStylesTrashKeepDays) days under" -ForegroundColor DarkGray Write-Host " $(Get-StyleTrashRoot)" -ForegroundColor DarkGray Write-Host " tstyles trash lists what is in there, with the days each has left" -ForegroundColor DarkGray Write-Host " tstyles restore <name> puts one back" -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 # The listing above named trash this delete would erase, in red, and the # user consented to it as part of one act. Saying the delete failed does # not say that half did not happen, and the failure paths through # Move-Item throw before the sweep can be reported any other way. if (@($plan.SweepTargets).Count -gt 0) { Write-Host " Nothing was erased: the trash is swept only after the move lands." -ForegroundColor DarkGray } return } Write-Host "" Write-Host " Deleted $($plan.Name)." -ForegroundColor Green Write-Host " Kept for $($script:TStylesTrashKeepDays) days at $($plan.TrashPath)" -ForegroundColor Gray # The recovery route, on the screen where it is needed. It was a `Move-Item` # the user had to compose from the path above, documented only in # `tstyles help delete`. Write-Host " Undo with: tstyles restore $($plan.Name)" -ForegroundColor DarkGray 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. The cost of that order is that the style is # no longer where the reset looks for it: -KnownStyleName carries the # ownership the plan proved while it was still there, or the reset refuses # the one profile it is certain about and the RESET line above is a lie. if ($plan.WasActive) { if ($plan.RevealDir) { Apply-StyleDirect -StyleName $plan.Name -Target $Target } else { Reset-StyleDirect -Target $Target -KnownStyleName $plan.Name } } } |