From 28218f85d3f703e81e4a0dc83b5190ced05f300a Mon Sep 17 00:00:00 2001 From: Jean-Paul van Ravensberg <14926452+DevSecNinja@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:42:13 +0200 Subject: [PATCH] feat(fastfetch): date the updates line and quieten pwsh startup Three papercuts from #645, reported after using it for a day. The Updates line never said how old its numbers were, so "6 updates available" could equally be from a minute or an hour ago. Unix already had a token renderer for exactly this (relative times are stored as absolute epochs and expanded at emit, so they never freeze in the cache); generalize it so tokens expand to a bare duration and the collectors own the wording, then add "checked 5m ago" to the updates line on both platforms. Windows cannot expand at emit time -- fastfetch reads the cache with `cmd /c type` -- so each section is now stored twice: .src keeps the token and is the rendered copy, re-rendered on every shell start just before fastfetch runs. That render lives in DotfilesHelpers because the profile already imports it, where dot-sourcing status.ps1 would add ~60ms to profile load. profile.ps1 loses 45 lines as a result. Opening a shell also printed four lines that fastfetch already covers: PowerShell 7.6.4 [OK] PowerShell Profile Loaded (1364 ms) >> Type 'aliases' to see available aliases Loading personal and system profiles took 1513ms. The first and last are printed by pwsh itself around profile load, so they cannot be suppressed from profile.ps1; add Set-WindowsTerminalProfileCommandLine and a run_onchange script that launches the Windows Terminal profile as `pwsh.exe -NoLogo -NoProfileLoadTime`. The middle two are ours and are now skipped when the fastfetch banner rendered -- a light install without fastfetch keeps them, so an interactive shell still confirms the profile loaded. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ff40b19-afbf-48c8-9b80-233b13652222 --- docs/customization.md | 22 +- ...-windows-terminal-powershell-args.ps1.tmpl | 43 +++ .../dot_config/fastfetch/executable_status.sh | 35 +- home/dot_config/fastfetch/status.ps1 | 70 +++- .../DotfilesHelpers/DotfilesHelpers.psd1 | 10 + .../Public/FastfetchStatus.ps1 | 350 ++++++++++++++++++ .../Public/WindowsTerminal.ps1 | 143 +++++++ home/dot_config/powershell/profile.ps1 | 71 ++-- tests/bash/fastfetch-status.bats | 47 ++- tests/powershell/FastfetchStatus.Tests.ps1 | 202 +++++++++- tests/powershell/Profile.Tests.ps1 | 36 ++ tests/powershell/WindowsTerminal.Tests.ps1 | 141 +++++++ 12 files changed, 1085 insertions(+), 85 deletions(-) create mode 100644 home/.chezmoiscripts/windows/run_onchange_set-windows-terminal-powershell-args.ps1.tmpl create mode 100644 home/dot_config/powershell/modules/DotfilesHelpers/Public/FastfetchStatus.ps1 diff --git a/docs/customization.md b/docs/customization.md index 3ebd4dfb..6ce0a0d4 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -156,7 +156,7 @@ to say โ€” fastfetch hides a module entirely when it prints nothing, so a health machine stays clean: ```console -Updates: ๐Ÿ“ฆ 6 update(s) available (winget) +Updates: ๐Ÿ“ฆ 6 update(s) available (winget) ยท checked 5m ago Reboot: ๐Ÿ”„ Reboot required โ€” linux-image-generic Ansible: โœ… ansible-pull OK ยท ran 5m ago ยท next in 25m ``` @@ -178,6 +178,12 @@ the next login. On Windows fastfetch reads the cache with `cmd /c type` rather than starting PowerShell, which keeps the line free (a few milliseconds). +The `checked 5m ago` part is deliberately not stored in the cache โ€” that would +freeze it until the next hourly refresh. Each section is kept twice: a source +file holding the moment as an absolute timestamp, and the rendered line the +banner shows. Expanding one into the other is pure arithmetic and happens on +every shell start, so the age is always correct when you read it. + Refresh by hand โ€” useful right after installing updates: ```bash @@ -195,6 +201,20 @@ Refresh by hand โ€” useful right after installing updates: | `FASTFETCH_STATUS_CACHE_DIR` | Override the cache directory (Windows; testing) | | `ANSIBLE_PULL_WORKDIR` | ansible-pull checkout (default `/var/lib/ansible/local`) | +### A quiet PowerShell startup + +fastfetch already reports the shell version and everything else worth knowing, +so the noise around it is turned off rather than printed twice: + +- `PowerShell 7.6.4` and `Loading personal and system profiles took 1513ms.` are + printed by pwsh itself, before and after the profile runs. They cannot be + suppressed from `profile.ps1`, so the Windows Terminal PowerShell profile is + launched as `pwsh.exe -NoLogo -NoProfileLoadTime` instead (set by + `run_onchange_set-windows-terminal-powershell-args.ps1`). +- The dotfiles' own `[OK] PowerShell Profile Loaded` and `Type 'aliases'` lines + are skipped whenever the fastfetch banner rendered. On a light install without + fastfetch they stay, so an interactive shell still confirms the profile loaded. + ## Windows PATH On Windows, the setup adds `%OneDrive%\Portable Programs` to the user-scope diff --git a/home/.chezmoiscripts/windows/run_onchange_set-windows-terminal-powershell-args.ps1.tmpl b/home/.chezmoiscripts/windows/run_onchange_set-windows-terminal-powershell-args.ps1.tmpl new file mode 100644 index 00000000..d55bac56 --- /dev/null +++ b/home/.chezmoiscripts/windows/run_onchange_set-windows-terminal-powershell-args.ps1.tmpl @@ -0,0 +1,43 @@ +# {{ if eq .chezmoi.os "windows" }} +# Start the Windows Terminal PowerShell profile quietly. +# +# Two lines of noise appear before anything useful: +# +# PowerShell 7.6.4 +# Loading personal and system profiles took 1513ms. +# +# Both are printed by pwsh itself around profile load, so they cannot be +# suppressed from profile.ps1 -- the banner is written before the profile runs +# and the timing line after it. The only lever is the command line the terminal +# starts, hence -NoLogo -NoProfileLoadTime here. fastfetch already reports the +# shell version in its banner, so nothing is lost. +# +# pwsh.exe is left to PATH resolution rather than pinned to an absolute path so +# the profile keeps working across PowerShell upgrades and reinstalls. +# +# As with the sibling default-profile script, Windows Terminal owns +# settings.json and every machine has a different, hand-tuned config, so only +# this one profile's commandline is touched. +$ErrorActionPreference = "Stop" + +$modulePath = "{{ .chezmoi.sourceDir }}\dot_config\powershell\modules\DotfilesHelpers" +if (Test-Path $modulePath) { + Import-Module $modulePath -Force -DisableNameChecking +} +else { + Write-Error "DotfilesHelpers module not found at: $modulePath" + exit 1 +} + +Write-Host "Setting Windows Terminal PowerShell startup arguments..." -ForegroundColor Cyan +$results = Set-WindowsTerminalProfileCommandLine ` + -Source "Windows.Terminal.PowershellCore" ` + -CommandLine "pwsh.exe -NoLogo -NoProfileLoadTime" + +if (-not $results) { + Write-Host "[SKIP] No Windows Terminal settings found to update" -ForegroundColor Yellow +} +elseif (-not ($results | Where-Object { $_.Status -in @('Updated', 'AlreadySet') })) { + Write-Host "[SKIP] PowerShell Core profile not found; leaving commandline unchanged" -ForegroundColor Yellow +} +# {{ end }} diff --git a/home/dot_config/fastfetch/executable_status.sh b/home/dot_config/fastfetch/executable_status.sh index bb3d084a..8594cd04 100755 --- a/home/dot_config/fastfetch/executable_status.sh +++ b/home/dot_config/fastfetch/executable_status.sh @@ -18,11 +18,12 @@ # # Relative times ("ran 5m ago", "next in 20m") are NOT baked into the cache # โ€” that would freeze them until the next refresh. The collectors store -# absolute epoch tokens (@ago:@ / @in:@) and the section -# commands expand them against the current clock on every fastfetch run. -# Expansion itself is pure shell arithmetic; reading the clock uses the -# EPOCHSECONDS builtin on bash >= 5.0 and falls back to one `date` call on -# older bash, so the emit path costs at most a single fork. +# absolute epoch tokens (@ago:@ / @in:@) that expand to a bare +# duration phrase ("5m ago" / "in 20m"), and the section commands expand them +# against the current clock on every fastfetch run. Expansion itself is pure +# shell arithmetic; reading the clock uses the EPOCHSECONDS builtin on +# bash >= 5.0 and falls back to one `date` call on older bash, so the emit +# path costs at most a single fork. # # Usage: status.sh # updates Print cached "updates available" line (may be empty) @@ -90,8 +91,10 @@ _now() { } # _render LINE NOW -> print LINE with relative-time tokens expanded: -# @ago:@ -> "ran 5m ago" (elapsed since ) -# @in:@ -> "next in 20m", or "next due" once has passed +# @ago:@ -> "5m ago" (elapsed since ) +# @in:@ -> "in 20m", or "due" once has passed +# Tokens expand to a bare duration phrase; the collectors own the wording +# around them (e.g. "ran @ago:โ€ฆ@", "next @in:โ€ฆ@", "checked @ago:โ€ฆ@"). # Unknown or malformed tokens are left untouched. _render() { local line="${1}" now="${2}" tail="" pre kind epoch token @@ -103,12 +106,12 @@ _render() { token="" if [ "${kind}" = "ago" ]; then _fmt_duration "$((now - epoch))" - token="ran ${REL_DURATION} ago" + token="${REL_DURATION} ago" elif [ "${epoch}" -gt "${now}" ]; then _fmt_duration "$((epoch - now))" - token="next in ${REL_DURATION}" + token="in ${REL_DURATION}" else - token="next due" + token="due" fi tail="${token}${BASH_REMATCH[4]}${tail}" line="${pre}" @@ -194,7 +197,13 @@ _collect_updates() { count="${count//[!0-9]/}" [ -z "${count}" ] && count=0 if [ "${count}" -gt 0 ]; then - printf '\360\237\223\246 %s update(s) available (%s)\n' "${count}" "${mgr}" + # The "checked" suffix is an absolute epoch token so it keeps counting + # up between refreshes instead of freezing at the value it had when + # the cache was written. + local checked_at + checked_at="$(_now)" + printf '\360\237\223\246 %s update(s) available (%s) \302\267 checked @ago:%s@\n' \ + "${count}" "${mgr}" "${checked_at}" fi } @@ -273,7 +282,7 @@ _collect_ansible() { if [ -n "${finished_ts}" ]; then local finished_epoch finished_epoch="$(date -d "${finished_ts}" +%s 2>/dev/null || echo 0)" - [ "${finished_epoch}" -gt 0 ] && ran="@ago:${finished_epoch}@" + [ "${finished_epoch}" -gt 0 ] && ran="ran @ago:${finished_epoch}@" fi # Next scheduled run from the timer (microseconds since epoch). @@ -282,7 +291,7 @@ _collect_ansible() { next_us="${next_us//[!0-9]/}" if [ -n "${next_us}" ] && [ "${next_us}" -gt 0 ]; then next_epoch=$((next_us / 1000000)) - [ "${next_epoch}" -gt "${now}" ] && next="@in:${next_epoch}@" + [ "${next_epoch}" -gt "${now}" ] && next="next @in:${next_epoch}@" fi local head tail="" part diff --git a/home/dot_config/fastfetch/status.ps1 b/home/dot_config/fastfetch/status.ps1 index f454c714..bd2e8f0e 100644 --- a/home/dot_config/fastfetch/status.ps1 +++ b/home/dot_config/fastfetch/status.ps1 @@ -19,6 +19,14 @@ * The PowerShell profile spawns a detached 'refresh' when the cache is older than the TTL, so the next login shows fresh numbers. + Each section is stored twice: '.src' keeps relative times as absolute + epoch tokens (@ago:@), and '' is the rendered copy fastfetch + reads. Baking "checked 5m ago" straight into the rendered file would freeze + it until the next refresh an hour later, so the tokens are expanded on every + shell start by Update-FastfetchStatusCache (DotfilesHelpers), which the + profile already imports. This script calls the same function after a refresh + so a manual 'refresh' also leaves a correctly rendered cache behind. + fastfetch hides a `command` module entirely when it prints nothing, so a host with no pending updates simply does not get an "Updates" line. @@ -59,10 +67,12 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' -# U+1F4E6 PACKAGE. Built from its code point because .ps1 files in this repo -# must stay ASCII (see .github/copilot-instructions.md): PowerShell 5.1 reads -# unsigned scripts without a BOM as ANSI, which corrupts literal emoji. +# U+1F4E6 PACKAGE and U+00B7 MIDDLE DOT. Built from their code points because +# .ps1 files in this repo must stay ASCII (see .github/copilot-instructions.md): +# PowerShell 5.1 reads unsigned scripts without a BOM as ANSI, which corrupts +# literal non-ASCII characters. $script:PackageIcon = [char]::ConvertFromUtf32(0x1F4E6) +$script:MiddleDot = [char]::ConvertFromUtf32(0x00B7) function Get-StatusCacheDir { if ($env:FASTFETCH_STATUS_CACHE_DIR) { return $env:FASTFETCH_STATUS_CACHE_DIR } @@ -171,19 +181,32 @@ function Get-UpdatesLine { <# .SYNOPSIS The "N update(s) available (winget)" line, or an empty string. + + .DESCRIPTION + The "checked" suffix is emitted as an absolute epoch token rather than a + formatted duration, so it keeps counting up on every shell start instead of + freezing at the value it had when the cache was written. #> [CmdletBinding()] param() $count = Get-WingetUpdateCount if ($null -eq $count -or $count -le 0) { return '' } - return ('{0} {1} update(s) available (winget)' -f $script:PackageIcon, $count) + + $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + return ('{0} {1} update(s) available (winget) {2} checked @ago:{3}@' -f + $script:PackageIcon, $count, $script:MiddleDot, $now) } function Write-StatusSection { <# .SYNOPSIS - Atomically write a cache section, or remove it when the value is empty. + Atomically write a cache section source, or remove it when the value is empty. + + .DESCRIPTION + Writes '.src' (with relative-time tokens intact). The rendered + '' file that fastfetch reads is produced from it by + Update-FastfetchStatusCache. #> [CmdletBinding(SupportsShouldProcess)] param( @@ -191,11 +214,13 @@ function Write-StatusSection { [Parameter(Mandatory)][AllowEmptyString()][string]$Value ) - $file = Join-Path (Get-StatusCacheDir) $Name + $cacheDir = Get-StatusCacheDir + $file = Join-Path $cacheDir "$Name.src" if (-not $PSCmdlet.ShouldProcess($file, 'Write status section')) { return } if ([string]::IsNullOrEmpty($Value)) { Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $cacheDir $Name) -Force -ErrorAction SilentlyContinue return } @@ -207,6 +232,38 @@ function Write-StatusSection { Move-Item -LiteralPath $tmp -Destination $file -Force } +function Invoke-StatusRender { + <# + .SYNOPSIS + Expand the cached tokens into the rendered files fastfetch reads. + + .DESCRIPTION + Rendering lives in the DotfilesHelpers module because the PowerShell profile + imports it anyway, so expanding tokens on every shell start is free there + (dot-sourcing this script instead would add ~60ms to profile load). This + script imports the module so a manual 'refresh' also leaves a correctly + rendered cache behind; when the module is missing the rendered files are + simply left to the next shell start. + #> + [CmdletBinding()] + param() + + try { + if (-not (Get-Command Update-FastfetchStatusCache -ErrorAction SilentlyContinue)) { + $modulePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'powershell\modules\DotfilesHelpers' + if (-not (Test-Path -LiteralPath $modulePath)) { + Write-Verbose "DotfilesHelpers not found at '$modulePath'; skipping render." + return + } + Import-Module $modulePath -DisableNameChecking -ErrorAction Stop + } + Update-FastfetchStatusCache -SkipRefresh + } + catch { + Write-Verbose "Could not render the status cache: $($_.Exception.Message)" + } +} + function Invoke-StatusEmit { [CmdletBinding()] param([Parameter(Mandatory)][string]$Name) @@ -264,6 +321,7 @@ function Invoke-StatusRefresh { Write-StatusSection -Name 'updates' -Value (Get-UpdatesLine) $stamp = Join-Path $cacheDir '.refreshed-at' [System.IO.File]::WriteAllText($stamp, '') + Invoke-StatusRender } finally { $handle.Dispose() diff --git a/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 b/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 index f7184ba2..0e9a7d98 100644 --- a/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 +++ b/home/dot_config/powershell/modules/DotfilesHelpers/DotfilesHelpers.psd1 @@ -51,6 +51,16 @@ # Windows Terminal settings 'Set-WindowsTerminalDefaultProfile' 'Set-WindowsTerminalCopilotProfile' + 'Set-WindowsTerminalProfileCommandLine' + + # fastfetch status cache + 'Get-FastfetchStatusCacheDir' + 'Format-FastfetchStatusDuration' + 'Expand-FastfetchStatusToken' + 'Update-FastfetchStatusCache' + 'Test-FastfetchStatusStale' + 'Start-FastfetchStatusRefresh' + 'Write-FastfetchStatusFile' # Office 365 mail aliases 'New-MailAlias' diff --git a/home/dot_config/powershell/modules/DotfilesHelpers/Public/FastfetchStatus.ps1 b/home/dot_config/powershell/modules/DotfilesHelpers/Public/FastfetchStatus.ps1 new file mode 100644 index 00000000..5445ae66 --- /dev/null +++ b/home/dot_config/powershell/modules/DotfilesHelpers/Public/FastfetchStatus.ps1 @@ -0,0 +1,350 @@ +# fastfetch status cache helpers (Windows) +# +# The fastfetch banner carries extra status lines (currently the winget update +# count) that are far too slow to compute while a shell starts, so they are +# served from a small cache under %LOCALAPPDATA%\fastfetch-status: +# +# updates.src the line as written by status.ps1, with relative-time +# tokens still in it (@ago:@ / @in:@) +# updates the rendered line, which fastfetch reads with `cmd /c type` +# .refreshed-at marker file whose mtime dates the cache +# +# Relative times are deliberately NOT baked into the rendered file by the +# refresh: that would freeze "checked 5m ago" until the next refresh an hour +# later. Instead the tokens are expanded on every shell start, just before +# fastfetch runs. That is what Update-FastfetchStatusCache does, and it is why +# it lives in this module (which the profile imports anyway) rather than in +# status.ps1 (dot-sourcing that script costs ~60ms of profile load). +# +# This mirrors home/dot_config/fastfetch/executable_status.sh, which expands +# the same token syntax inline because its emit path is already a shell script. + +function Get-FastfetchStatusCacheDir { + <# + .SYNOPSIS + Path of the fastfetch status cache directory. + + .DESCRIPTION + Honours FASTFETCH_STATUS_CACHE_DIR (used by tests and by anyone who wants + the cache somewhere else), then %LOCALAPPDATA%, then ~/.cache as a last + resort. Kept in sync with the same lookup in status.ps1. + + .EXAMPLE + Get-FastfetchStatusCacheDir + #> + [CmdletBinding()] + [OutputType([string])] + param() + + if ($env:FASTFETCH_STATUS_CACHE_DIR) { return $env:FASTFETCH_STATUS_CACHE_DIR } + $base = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { Join-Path $HOME '.cache' } + return (Join-Path $base 'fastfetch-status') +} + +function Format-FastfetchStatusDuration { + <# + .SYNOPSIS + Formats a number of seconds as a compact duration (45s, 12m, 3h, 2d). + + .PARAMETER Seconds + Seconds to format. Negative values are treated as zero, which happens when + a clock change leaves a cache timestamp slightly in the future. + + .EXAMPLE + Format-FastfetchStatusDuration -Seconds 3600 + Returns '1h'. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [double]$Seconds + ) + + $s = [int][math]::Floor($Seconds) + if ($s -lt 0) { $s = 0 } + + if ($s -lt 60) { return "${s}s" } + if ($s -lt 3600) { return "$([int][math]::Floor($s / 60))m" } + if ($s -lt 86400) { return "$([int][math]::Floor($s / 3600))h" } + return "$([int][math]::Floor($s / 86400))d" +} + +function Expand-FastfetchStatusToken { + <# + .SYNOPSIS + Expands relative-time tokens in a cached status line. + + .DESCRIPTION + Replaces the absolute epoch tokens written by the collectors with a bare + duration phrase, so the surrounding wording stays in the collector: + + @ago:@ -> '5m ago' + @in:@ -> 'in 20m', or 'due' once the moment has passed + + Malformed tokens are left untouched rather than throwing, so a corrupt + cache degrades to a slightly ugly line instead of breaking the banner. + + .PARAMETER Line + The cached line to expand. + + .PARAMETER Now + Current time as Unix epoch seconds. Defaults to the current clock. + + .EXAMPLE + Expand-FastfetchStatusToken -Line 'checked @ago:1700000000@' + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Now', Justification = 'Captured by the regex MatchEvaluator closure below, which the analyzer cannot see into.')] + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$Line, + + [Parameter()] + [long]$Now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + ) + + $evaluator = { + param($match) + + $kind = $match.Groups[1].Value + $epoch = [long]$match.Groups[2].Value + + if ($kind -eq 'ago') { + return ('{0} ago' -f (Format-FastfetchStatusDuration -Seconds ($Now - $epoch))) + } + if ($epoch -gt $Now) { + return ('in {0}' -f (Format-FastfetchStatusDuration -Seconds ($epoch - $Now))) + } + return 'due' + } + + return [regex]::Replace($Line, '@(ago|in):(\d+)@', $evaluator) +} + +function Update-FastfetchStatusCache { + <# + .SYNOPSIS + Renders the fastfetch status cache and refreshes it in the background when stale. + + .DESCRIPTION + Call this from an interactive profile just before running fastfetch. It does + two cheap things and never blocks: + + 1. Expands every '
.src' in the cache into its rendered + '
' sibling, so relative times are correct as of right now. + 2. When the cache is older than the TTL, starts a detached + 'status.ps1 refresh' so the NEXT shell sees fresh numbers. The current + shell deliberately keeps showing the stale-but-instant values + (stale-while-revalidate); a winget query takes tens of seconds and must + never sit on the login path. + + Rendered files whose '.src' has disappeared are removed, which is how a + section stops being displayed once it has nothing to report (fastfetch hides + a 'command' module entirely when it prints nothing). + + Failures are swallowed: a missing status line is never a reason to break a + shell. + + .PARAMETER SkipRefresh + Only render; never spawn a background refresh. Used by status.ps1 itself + after a refresh it has just completed. + + .PARAMETER CacheDir + Cache directory. Defaults to Get-FastfetchStatusCacheDir. + + .PARAMETER StatusScript + Path to status.ps1. Defaults to the copy next to this module in the chezmoi + tree (~/.config/fastfetch/status.ps1). + + .EXAMPLE + Update-FastfetchStatusCache + Renders the cache and kicks off a background refresh when it is stale. + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter()] + [switch]$SkipRefresh, + + [Parameter()] + [string]$CacheDir, + + [Parameter()] + [string]$StatusScript + ) + + if ($env:FASTFETCH_STATUS_DISABLE -eq '1') { return } + + if (-not $CacheDir) { $CacheDir = Get-FastfetchStatusCacheDir } + if (-not (Test-Path -LiteralPath $CacheDir)) { + # Nothing cached yet. Still worth spawning the first refresh. + if (-not $SkipRefresh) { Start-FastfetchStatusRefresh -StatusScript $StatusScript } + return + } + + $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + + foreach ($src in @(Get-ChildItem -LiteralPath $CacheDir -Filter '*.src' -File -ErrorAction SilentlyContinue)) { + $rendered = Join-Path $CacheDir ([System.IO.Path]::GetFileNameWithoutExtension($src.Name)) + try { + $lines = @(Get-Content -LiteralPath $src.FullName -Encoding UTF8 -ErrorAction Stop | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { Expand-FastfetchStatusToken -Line $_ -Now $now }) + + if (-not $PSCmdlet.ShouldProcess($rendered, 'Render fastfetch status section')) { continue } + + if ($lines.Count -eq 0) { + Remove-Item -LiteralPath $rendered -Force -ErrorAction SilentlyContinue + continue + } + + Write-FastfetchStatusFile -Path $rendered -Lines $lines + } + catch { + Write-Verbose "Could not render fastfetch status section '$rendered': $($_.Exception.Message)" + } + } + + # Drop rendered files whose source is gone (section no longer applies). + foreach ($stale in @(Get-ChildItem -LiteralPath $CacheDir -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notlike '*.src' -and $_.Name -notlike '.*' })) { + if (-not (Test-Path -LiteralPath "$($stale.FullName).src")) { + if ($PSCmdlet.ShouldProcess($stale.FullName, 'Remove orphaned fastfetch status section')) { + Remove-Item -LiteralPath $stale.FullName -Force -ErrorAction SilentlyContinue + } + } + } + + if (-not $SkipRefresh -and (Test-FastfetchStatusStale -CacheDir $CacheDir)) { + Start-FastfetchStatusRefresh -StatusScript $StatusScript + } +} + +function Test-FastfetchStatusStale { + <# + .SYNOPSIS + True when the fastfetch status cache is missing or older than the TTL. + + .PARAMETER CacheDir + Cache directory. Defaults to Get-FastfetchStatusCacheDir. + + .EXAMPLE + Test-FastfetchStatusStale + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter()] + [string]$CacheDir + ) + + if (-not $CacheDir) { $CacheDir = Get-FastfetchStatusCacheDir } + $stamp = Join-Path $CacheDir '.refreshed-at' + if (-not (Test-Path -LiteralPath $stamp)) { return $true } + + $ttl = 3600 + $parsed = 0 + if ($env:FASTFETCH_STATUS_TTL -and + [int]::TryParse($env:FASTFETCH_STATUS_TTL, [ref]$parsed) -and + $parsed -gt 0) { + $ttl = $parsed + } + + $age = (Get-Date) - (Get-Item -LiteralPath $stamp).LastWriteTime + return ($age.TotalSeconds -ge $ttl) +} + +function Start-FastfetchStatusRefresh { + <# + .SYNOPSIS + Starts a detached 'status.ps1 refresh' without blocking the caller. + + .DESCRIPTION + The refresh queries winget, which takes tens of seconds, so it runs in a + separate hidden process that nobody waits on. Its output is silenced inside + the child as well as redirected, because no one drains those pipes: a chatty + child would otherwise block once the pipe buffer filled. + + .PARAMETER StatusScript + Path to status.ps1. Defaults to ~/.config/fastfetch/status.ps1. + + .EXAMPLE + Start-FastfetchStatusRefresh + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter()] + [string]$StatusScript + ) + + if (-not $StatusScript) { + $StatusScript = Join-Path $HOME '.config\fastfetch\status.ps1' + } + if (-not (Test-Path -LiteralPath $StatusScript)) { + Write-Verbose "fastfetch status script not found at: $StatusScript" + return + } + + $psHost = (Get-Process -Id $PID).Path + if (-not $psHost) { + Write-Verbose 'Could not resolve the current PowerShell host executable.' + return + } + + if (-not $PSCmdlet.ShouldProcess($StatusScript, 'Start background fastfetch status refresh')) { return } + + try { + $command = "& '{0}' refresh *>`$null" -f $StatusScript.Replace("'", "''") + $info = [System.Diagnostics.ProcessStartInfo]::new($psHost) + $info.Arguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "{0}"' -f $command + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + [System.Diagnostics.Process]::Start($info) | Out-Null + } + catch { + Write-Verbose "Could not start fastfetch status refresh: $($_.Exception.Message)" + } +} + +function Write-FastfetchStatusFile { + <# + .SYNOPSIS + Atomically writes cache lines as UTF-8 without a BOM. + + .DESCRIPTION + fastfetch reads the rendered file with `cmd /c type`, which copies bytes + straight through, so a BOM would surface as stray characters in the banner. + The write goes to a temporary file first so a reader never sees a half + written line. + + .PARAMETER Path + Destination file. + + .PARAMETER Lines + Lines to write. + + .EXAMPLE + Write-FastfetchStatusFile -Path $cache -Lines '6 update(s) available' + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)] + [string]$Path, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [string[]]$Lines + ) + + if (-not $PSCmdlet.ShouldProcess($Path, 'Write fastfetch status file')) { return } + + $encoding = New-Object System.Text.UTF8Encoding($false) + $content = ($Lines -join "`r`n") + "`r`n" + $tmp = "$Path.tmp" + [System.IO.File]::WriteAllText($tmp, $content, $encoding) + Move-Item -LiteralPath $tmp -Destination $Path -Force +} diff --git a/home/dot_config/powershell/modules/DotfilesHelpers/Public/WindowsTerminal.ps1 b/home/dot_config/powershell/modules/DotfilesHelpers/Public/WindowsTerminal.ps1 index 6196a593..aa206370 100644 --- a/home/dot_config/powershell/modules/DotfilesHelpers/Public/WindowsTerminal.ps1 +++ b/home/dot_config/powershell/modules/DotfilesHelpers/Public/WindowsTerminal.ps1 @@ -363,6 +363,149 @@ function Set-WindowsTerminalDefaultProfile { return $results } +function Set-WindowsTerminalProfileCommandLine { + <# + .SYNOPSIS + Sets the commandline of an existing Windows Terminal profile. + + .DESCRIPTION + Parses each existing Windows Terminal settings.json, finds the matching + profile in profiles.list (by source, e.g. 'Windows.Terminal.PowershellCore', + or by display name), and surgically sets that profile's 'commandline'. Only + that one key is touched, so the rest of a hand-tuned config survives intact. + + This is how launch-time switches get applied: options like -NoLogo and + -NoProfileLoadTime are read by PowerShell before any profile runs, so they + cannot be set from within profile.ps1 and have to live on the command line + the terminal starts. + + Dynamically generated profiles (the ones carrying a 'source') accept an + explicit commandline, which overrides the generator's default. + + When no matching profile exists (e.g. PowerShell Core is not installed on + that machine) the file is left unchanged and the result is reported as + 'ProfileNotFound' rather than raising an error, so callers can skip cleanly. + + .PARAMETER CommandLine + The command line to set, e.g. 'pwsh.exe -NoLogo -NoProfileLoadTime'. + + .PARAMETER Source + The profile 'source' to match, e.g. 'Windows.Terminal.PowershellCore' + (default). Used when -ProfileName is not supplied. + + .PARAMETER ProfileName + Match a profile by its display 'name' instead of its 'source'. + + .PARAMETER SettingsPath + One or more settings.json paths. Defaults to the known Windows Terminal + locations (Store, Preview, and unpackaged). + + .EXAMPLE + Set-WindowsTerminalProfileCommandLine -CommandLine 'pwsh.exe -NoLogo' + Starts the PowerShell Core profile without the startup banner. + + .EXAMPLE + Set-WindowsTerminalProfileCommandLine -ProfileName 'Debian' -CommandLine 'wsl -d Debian' + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Matches sibling Windows Terminal helpers that print visible status after a change.')] + [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'BySource')] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$CommandLine, + + [Parameter(ParameterSetName = 'BySource')] + [ValidateNotNullOrEmpty()] + [string]$Source = 'Windows.Terminal.PowershellCore', + + [Parameter(Mandatory, ParameterSetName = 'ByName')] + [ValidateNotNullOrEmpty()] + [string]$ProfileName, + + [Parameter()] + [string[]]$SettingsPath + ) + + if (-not $SettingsPath) { + $SettingsPath = Get-WindowsTerminalSettingsPath + } + + if ($PSCmdlet.ParameterSetName -eq 'ByName') { + $criteria = "name '$ProfileName'" + } + else { + $criteria = "source '$Source'" + } + + $results = @() + + foreach ($path in $SettingsPath) { + if (-not (Test-Path -LiteralPath $path)) { + Write-Verbose "Windows Terminal settings not found at: $path (skipping)" + continue + } + + try { + $json = Read-WindowsTerminalSettings -Path $path + + $list = $null + if ($json.PSObject.Properties['profiles'] -and $json.profiles.PSObject.Properties['list']) { + $list = @($json.profiles.list) + } + + if (-not $list) { + Write-Verbose "No profiles.list found in: $path (skipping)" + $results += [pscustomobject]@{ Path = $path; Changed = $false; Status = 'ProfileNotFound'; MatchedProfile = $null } + continue + } + + if ($PSCmdlet.ParameterSetName -eq 'ByName') { + $match = $list | Where-Object { $_.PSObject.Properties['name'] -and $_.name -eq $ProfileName } | Select-Object -First 1 + } + else { + $match = $list | Where-Object { $_.PSObject.Properties['source'] -and $_.source -eq $Source } | Select-Object -First 1 + } + + if (-not $match) { + Write-Verbose "No profile matching $criteria found in: $path (skipping)" + $results += [pscustomobject]@{ Path = $path; Changed = $false; Status = 'ProfileNotFound'; MatchedProfile = $null } + continue + } + + $name = if ($match.PSObject.Properties['name']) { [string]$match.name } else { $criteria } + + if ($match.PSObject.Properties['commandline'] -and [string]$match.commandline -eq $CommandLine) { + Write-Verbose "commandline already set for $criteria at: $path" + $results += [pscustomobject]@{ Path = $path; Changed = $false; Status = 'AlreadySet'; MatchedProfile = $name } + continue + } + + if ($null -eq $match.PSObject.Properties['commandline']) { + $match | Add-Member -NotePropertyName commandline -NotePropertyValue $CommandLine -Force + } + else { + $match.commandline = $CommandLine + } + + if ($PSCmdlet.ShouldProcess($path, "Set commandline for '$name' to $CommandLine")) { + Save-WindowsTerminalSettings -Path $path -Settings $json + Write-Host "[OK] Set Windows Terminal commandline for '$name' at: $path" -ForegroundColor Green + $results += [pscustomobject]@{ Path = $path; Changed = $true; Status = 'Updated'; MatchedProfile = $name } + } + else { + $results += [pscustomobject]@{ Path = $path; Changed = $false; Status = 'WhatIf'; MatchedProfile = $name } + } + } + catch { + Write-Warning "Could not update Windows Terminal commandline at '$path': $_" + $results += [pscustomobject]@{ Path = $path; Changed = $false; Status = 'Error'; MatchedProfile = $null } + } + } + + return $results +} + function Set-WindowsTerminalCopilotProfile { <# .SYNOPSIS diff --git a/home/dot_config/powershell/profile.ps1 b/home/dot_config/powershell/profile.ps1 index 3c901fbc..4b4c1e0f 100644 --- a/home/dot_config/powershell/profile.ps1 +++ b/home/dot_config/powershell/profile.ps1 @@ -6,6 +6,10 @@ # Measure profile load time for performance diagnostics $script:_profileLoadStart = [System.Diagnostics.Stopwatch]::StartNew() +# Set when the fastfetch banner rendered, so the welcome lines below can stay +# out of the way. +$script:_fastfetchShown = $false + # Set UTF-8 encoding (force code page 65001 for Windows PowerShell 5.1) if ($PSVersionTable.PSVersion.Major -lt 7) { chcp 65001 | Out-Null @@ -112,54 +116,17 @@ if (Test-Path $completionsPath) { if ([Environment]::UserInteractive -and -not $env:CHEZMOI_SOURCE_DIR) { $fastfetchCmd = Get-Command fastfetch -ErrorAction SilentlyContinue if ($fastfetchCmd) { - # Stale-while-revalidate for the extra "Updates" module: fastfetch reads - # a cache file with `cmd /c type`, and the expensive winget query runs - # here in a detached background process. The banner below therefore - # shows the previous (possibly slightly stale) numbers instantly while a - # fresh copy is computed for the next login. Mirrors status.sh on Unix, - # which can self-spawn its refresh because its emit path is already a - # shell script. - try { - $statusScript = Join-Path (Split-Path $PSScriptRoot -Parent) 'fastfetch\status.ps1' - $statusCacheDir = if ($env:FASTFETCH_STATUS_CACHE_DIR) { - $env:FASTFETCH_STATUS_CACHE_DIR - } else { - Join-Path $env:LOCALAPPDATA 'fastfetch-status' - } - $statusStamp = Join-Path $statusCacheDir '.refreshed-at' - - $statusTtlSeconds = 3600 - $parsedTtl = 0 - if ($env:FASTFETCH_STATUS_TTL -and - [int]::TryParse($env:FASTFETCH_STATUS_TTL, [ref]$parsedTtl) -and - $parsedTtl -gt 0) { - $statusTtlSeconds = $parsedTtl - } - - $statusStale = -not (Test-Path -LiteralPath $statusStamp) - if (-not $statusStale) { - $statusAge = (Get-Date) - (Get-Item -LiteralPath $statusStamp).LastWriteTime - $statusStale = $statusAge.TotalSeconds -ge $statusTtlSeconds - } - - if ($statusStale -and $env:FASTFETCH_STATUS_DISABLE -ne '1' -and (Test-Path -LiteralPath $statusScript)) { - $statusHost = (Get-Process -Id $PID).Path - if ($statusHost) { - # Output is silenced inside the child (*>$null) as well as - # redirected, because nobody drains these pipes: a chatty - # child would otherwise block once the pipe buffer filled. - $statusCommand = "& '{0}' refresh *>`$null" -f $statusScript.Replace("'", "''") - $statusInfo = [System.Diagnostics.ProcessStartInfo]::new($statusHost) - $statusInfo.Arguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "{0}"' -f $statusCommand - $statusInfo.UseShellExecute = $false - $statusInfo.CreateNoWindow = $true - $statusInfo.RedirectStandardOutput = $true - $statusInfo.RedirectStandardError = $true - [System.Diagnostics.Process]::Start($statusInfo) | Out-Null - } + # Render the extra status lines (winget updates) and, when the cache has + # aged out, kick off a detached refresh for the next shell. Both are + # cheap; the expensive winget query never runs on this path. See + # DotfilesHelpers/Public/FastfetchStatus.ps1. + if (Get-Command Update-FastfetchStatusCache -ErrorAction SilentlyContinue) { + try { + Update-FastfetchStatusCache + } catch { + # A missing status line is never a reason to break the shell. + Write-Verbose "fastfetch status cache update failed: $($_.Exception.Message)" } - } catch { - # A missing cache only costs an "Updates" line; never break the shell. } # Allow users to tune the timeout (milliseconds) via an env var. @@ -182,6 +149,8 @@ if ([Environment]::UserInteractive -and -not $env:CHEZMOI_SOURCE_DIR) { if (-not $fastfetchProc.WaitForExit($fastfetchTimeoutMs)) { try { $fastfetchProc.Kill() } catch { } Write-Host "`n(fastfetch timed out after $([math]::Round($fastfetchTimeoutMs / 1000, 1))s; skipping)" -ForegroundColor DarkYellow + } else { + $script:_fastfetchShown = $true } } catch { Write-Host "(fastfetch failed to start: $($_.Exception.Message))" -ForegroundColor DarkYellow @@ -190,12 +159,16 @@ if ([Environment]::UserInteractive -and -not $env:CHEZMOI_SOURCE_DIR) { # Function/alias/cmdlet: no reliable way to cancel cooperatively, # so just invoke it directly. fastfetch + $script:_fastfetchShown = $true } } } -# Welcome message (only in interactive sessions) -if ([Environment]::UserInteractive -and -not $env:CHEZMOI_SOURCE_DIR) { +# Welcome message (only in interactive sessions). fastfetch already reports the +# shell and everything else worth knowing, so when its banner rendered these two +# lines are pure noise and are skipped. A light install without fastfetch keeps +# them, so an interactive shell still confirms the profile loaded. +if ([Environment]::UserInteractive -and -not $env:CHEZMOI_SOURCE_DIR -and -not $script:_fastfetchShown) { $script:_profileLoadStart.Stop() $loadTimeMs = $script:_profileLoadStart.ElapsedMilliseconds Write-Host "[OK] PowerShell Profile Loaded ($loadTimeMs ms)" -ForegroundColor Green diff --git a/tests/bash/fastfetch-status.bats b/tests/bash/fastfetch-status.bats index 205c16f8..68a0aeef 100644 --- a/tests/bash/fastfetch-status.bats +++ b/tests/bash/fastfetch-status.bats @@ -118,6 +118,24 @@ EOF [[ "$output" =~ "2 update(s) available (apt)" ]] } +@test "fastfetch-status: updates line reports when it was calculated" { + _mock apt-get <<'EOF' +#!/bin/bash +echo 'Inst bash [5.2] (5.2.15 Debian:13 [arm64])' +EOF + run bash "${SCRIPT}" refresh + [ "$status" -eq 0 ] + + # Stored as an absolute epoch token so the age keeps advancing between + # refreshes rather than freezing at "checked 0s ago". + run cat "${XDG_CACHE_HOME}/fastfetch-status/updates" + [[ "$output" =~ checked\ @ago:[0-9]+@ ]] + + run bash "${SCRIPT}" updates + [ "$status" -eq 0 ] + [[ "$output" =~ "checked 0s ago" ]] +} + @test "fastfetch-status: no apt updates leaves updates section empty" { _mock apt-get <<'EOF' #!/bin/bash @@ -174,9 +192,10 @@ EOF [ "$status" -eq 0 ] # The cache stores absolute epoch tokens, not pre-rendered durations. run cat "${XDG_CACHE_HOME}/fastfetch-status/ansible" - [[ "$output" =~ @ago:[0-9]+@ ]] - [[ "$output" =~ @in:[0-9]+@ ]] - [[ ! "$output" =~ "ran " ]] + [[ "$output" =~ ran\ @ago:[0-9]+@ ]] + [[ "$output" =~ next\ @in:[0-9]+@ ]] + # ... and definitely not a duration that would freeze until the next refresh. + [[ ! "$output" =~ [0-9]+[smhd]\ ago ]] # Emitting expands them against the current clock. run bash "${SCRIPT}" ansible [ "$status" -eq 0 ] @@ -188,7 +207,7 @@ EOF @test "fastfetch-status: relative times advance without a cache refresh" { mkdir -p "${XDG_CACHE_HOME}/fastfetch-status" : >"${XDG_CACHE_HOME}/fastfetch-status/.refreshed-at" - printf 'ansible-pull OK \302\267 @ago:%s@\n' "$(($(date +%s) - 3540))" \ + printf 'ansible-pull OK \302\267 ran @ago:%s@\n' "$(($(date +%s) - 3540))" \ >"${XDG_CACHE_HOME}/fastfetch-status/ansible" run bash "${SCRIPT}" ansible @@ -196,7 +215,7 @@ EOF [[ "$output" =~ "ran 59m ago" ]] # Same cache file, later clock -> a different rendered duration. - printf 'ansible-pull OK \302\267 @ago:%s@\n' "$(($(date +%s) - 3660))" \ + printf 'ansible-pull OK \302\267 ran @ago:%s@\n' "$(($(date +%s) - 3660))" \ >"${XDG_CACHE_HOME}/fastfetch-status/ansible" run bash "${SCRIPT}" ansible [ "$status" -eq 0 ] @@ -206,7 +225,7 @@ EOF @test "fastfetch-status: elapsed next-run token renders as due" { mkdir -p "${XDG_CACHE_HOME}/fastfetch-status" : >"${XDG_CACHE_HOME}/fastfetch-status/.refreshed-at" - printf 'ansible-pull OK \302\267 @in:%s@\n' "$(($(date +%s) - 60))" \ + printf 'ansible-pull OK \302\267 next @in:%s@\n' "$(($(date +%s) - 60))" \ >"${XDG_CACHE_HOME}/fastfetch-status/ansible" run bash "${SCRIPT}" ansible @@ -214,6 +233,22 @@ EOF [[ "$output" =~ "next due" ]] } +@test "fastfetch-status: tokens expand to a bare duration phrase" { + # The wording around a token belongs to the collector, so the same token + # can read as "checked 5m ago" or "ran 5m ago" depending on the section. + mkdir -p "${XDG_CACHE_HOME}/fastfetch-status" + : >"${XDG_CACHE_HOME}/fastfetch-status/.refreshed-at" + printf 'checked @ago:%s@ \302\267 due @in:%s@\n' \ + "$(($(date +%s) - 300))" "$(($(date +%s) + 600))" \ + >"${XDG_CACHE_HOME}/fastfetch-status/updates" + + run bash "${SCRIPT}" updates + [ "$status" -eq 0 ] + [[ "$output" =~ "checked 5m ago" ]] + [[ "$output" =~ "due in 10m" ]] + [[ ! "$output" =~ "ran " ]] +} + @test "fastfetch-status: lines without tokens are emitted verbatim" { mkdir -p "${XDG_CACHE_HOME}/fastfetch-status" : >"${XDG_CACHE_HOME}/fastfetch-status/.refreshed-at" diff --git a/tests/powershell/FastfetchStatus.Tests.ps1 b/tests/powershell/FastfetchStatus.Tests.ps1 index 77b9b7de..d0830fa0 100644 --- a/tests/powershell/FastfetchStatus.Tests.ps1 +++ b/tests/powershell/FastfetchStatus.Tests.ps1 @@ -206,16 +206,35 @@ Describe "fastfetch status.ps1 update line" { It "should report the count and the manager" { Mock Get-WingetUpdateCount { 6 } - Get-UpdatesLine | Should -Match '^\S+ 6 update\(s\) available \(winget\)$' + Get-UpdatesLine | Should -Match '^\S+ 6 update\(s\) available \(winget\) \S+ checked @ago:\d+@$' } - It "refresh should write a cache file and a stamp" { + It "should date the line with a token rather than a frozen duration" { + # A pre-rendered "checked 0s ago" would stay 0s until the next refresh + # an hour later, so the epoch has to survive into the cache. + Mock Get-WingetUpdateCount { 6 } + $line = Get-UpdatesLine + $line | Should -Not -Match '\d+[smhd] ago' + $line | Should -Match '@ago:\d+@' + } + + It "refresh should write a source file and a stamp" { Mock Get-WingetUpdateCount { 4 } Invoke-StatusRefresh Join-Path $script:TestCacheDir '.refreshed-at' | Should -Exist + $line = Get-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') -Raw + $line | Should -Match '4 update\(s\) available \(winget\)' + } + + It "refresh should render the source into the file fastfetch reads" { + Mock Get-WingetUpdateCount { 4 } + Invoke-StatusRefresh + $line = Get-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates') -Raw $line | Should -Match '4 update\(s\) available \(winget\)' + $line | Should -Match 'checked \d+[smhd] ago' + $line | Should -Not -Match '@ago:' } It "refresh should write the cache as UTF-8 without a BOM so cmd /c type stays clean" { @@ -226,7 +245,7 @@ Describe "fastfetch status.ps1 update line" { $bytes[0] | Should -Not -Be 0xEF } - It "refresh should remove the cache file when nothing is outdated" { + It "refresh should remove both cache files when nothing is outdated" { Mock Get-WingetUpdateCount { 3 } Invoke-StatusRefresh Join-Path $script:TestCacheDir 'updates' | Should -Exist @@ -234,6 +253,7 @@ Describe "fastfetch status.ps1 update line" { Mock Get-WingetUpdateCount { 0 } Invoke-StatusRefresh Join-Path $script:TestCacheDir 'updates' | Should -Not -Exist + Join-Path $script:TestCacheDir 'updates.src' | Should -Not -Exist } It "refresh should release the lock on completion" { @@ -313,17 +333,179 @@ Describe "fastfetch chezmoi wiring" { $script:IgnoreContent | Should -Not -Match '\.config/fastfetch/\*\*' } - It "profile should spawn a background refresh of the status cache" { - $script:ProfileContent | Should -Match 'fastfetch\\status\.ps1' - $script:ProfileContent | Should -Match "'refresh'|refresh" + It "profile should delegate the status cache to the module" { + # The render must run on every shell start (so "checked 5m ago" keeps + # counting), but dot-sourcing status.ps1 from the profile costs ~60ms, + # hence the module function the profile already has loaded. + $script:ProfileContent | Should -Match 'Update-FastfetchStatusCache' + } + + It "profile should not inline the refresh spawn any more" { + $script:ProfileContent | Should -Not -Match 'CreateNoWindow' + $script:ProfileContent | Should -Not -Match 'FASTFETCH_STATUS_TTL' + } + + It "profile should skip the welcome lines when fastfetch rendered" { + $script:ProfileContent | Should -Match '\$script:_fastfetchShown = \$true' + $script:ProfileContent | Should -Match '-not \$script:_fastfetchShown' + } + + It "profile should still show the welcome lines without fastfetch" { + # The welcome block must not be nested inside the fastfetch guard, or a + # light install would start completely silently. + $script:ProfileContent | Should -Match 'PowerShell Profile Loaded' } +} + +Describe "fastfetch status module helpers" { + BeforeAll { + Import-Module (Join-Path $script:RepoRoot "home\dot_config\powershell\modules\DotfilesHelpers") ` + -Force -DisableNameChecking + } + + Context "Format-FastfetchStatusDuration" { + It "should format s as ''" -ForEach @( + @{ Seconds = 0; Expected = '0s' } + @{ Seconds = 45; Expected = '45s' } + @{ Seconds = 59; Expected = '59s' } + @{ Seconds = 60; Expected = '1m' } + @{ Seconds = 3540; Expected = '59m' } + @{ Seconds = 3600; Expected = '1h' } + @{ Seconds = 86399; Expected = '23h' } + @{ Seconds = 86400; Expected = '1d' } + ) { + Format-FastfetchStatusDuration -Seconds $seconds | Should -Be $expected + } - It "profile background refresh should not create a visible window" { - $script:ProfileContent | Should -Match 'CreateNoWindow' + It "should clamp a negative age to zero (clock skew)" { + Format-FastfetchStatusDuration -Seconds -30 | Should -Be '0s' + } } - It "profile should honour FASTFETCH_STATUS_DISABLE" { - $script:ProfileContent | Should -Match 'FASTFETCH_STATUS_DISABLE' + Context "Expand-FastfetchStatusToken" { + BeforeAll { $script:Now = 1700000000 } + + It "should expand an elapsed token to a bare duration phrase" { + Expand-FastfetchStatusToken -Line "checked @ago:$($script:Now - 300)@" -Now $script:Now | + Should -Be 'checked 5m ago' + } + + It "should let the caller own the wording around a token" { + Expand-FastfetchStatusToken -Line "ran @ago:$($script:Now - 300)@" -Now $script:Now | + Should -Be 'ran 5m ago' + } + + It "should expand a future token" { + Expand-FastfetchStatusToken -Line "next @in:$($script:Now + 1200)@" -Now $script:Now | + Should -Be 'next in 20m' + } + + It "should report a passed future token as due" { + Expand-FastfetchStatusToken -Line "next @in:$($script:Now - 60)@" -Now $script:Now | + Should -Be 'next due' + } + + It "should expand several tokens in one line" { + $line = "OK - ran @ago:$($script:Now - 60)@, next @in:$($script:Now + 60)@" + Expand-FastfetchStatusToken -Line $line -Now $script:Now | + Should -Be 'OK - ran 1m ago, next in 1m' + } + + It "should leave unknown tokens untouched rather than throwing" { + Expand-FastfetchStatusToken -Line 'user@host @nope:1@ 100% done' -Now $script:Now | + Should -Be 'user@host @nope:1@ 100% done' + } + + It "should handle an empty line" { + Expand-FastfetchStatusToken -Line '' -Now $script:Now | Should -Be '' + } + } + + Context "Update-FastfetchStatusCache" { + BeforeEach { + if (Test-Path -LiteralPath $script:TestCacheDir) { + Remove-Item -LiteralPath $script:TestCacheDir -Recurse -Force + } + New-Item -ItemType Directory -Path $script:TestCacheDir -Force | Out-Null + # Fresh stamp so no background refresh is triggered by these tests. + Set-Content -LiteralPath (Join-Path $script:TestCacheDir '.refreshed-at') -Value '' + } + + It "should render a source file into its sibling" { + $epoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - 600 + Set-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') ` + -Value "6 update(s) available (winget) - checked @ago:$epoch@" + + Update-FastfetchStatusCache -SkipRefresh + + $rendered = Get-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates') -Raw + $rendered | Should -Match 'checked 10m ago' + } + + It "should re-render with the current clock, not the cached duration" { + $src = Join-Path $script:TestCacheDir 'updates.src' + $epoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - 60 + Set-Content -LiteralPath $src -Value "checked @ago:$epoch@" + Update-FastfetchStatusCache -SkipRefresh + (Get-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates') -Raw) | + Should -Match 'checked 1m ago' + + # Same source, older timestamp -> a different rendered duration, + # without any refresh having run. + $epoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - 7200 + Set-Content -LiteralPath $src -Value "checked @ago:$epoch@" + Update-FastfetchStatusCache -SkipRefresh + (Get-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates') -Raw) | + Should -Match 'checked 2h ago' + } + + It "should write UTF-8 without a BOM" { + Set-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') -Value 'plain line' + Update-FastfetchStatusCache -SkipRefresh + + $bytes = [System.IO.File]::ReadAllBytes((Join-Path $script:TestCacheDir 'updates')) + $bytes[0] | Should -Not -Be 0xEF + } + + It "should drop a rendered file whose source has gone" { + Set-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') -Value 'line' + Update-FastfetchStatusCache -SkipRefresh + Join-Path $script:TestCacheDir 'updates' | Should -Exist + + Remove-Item -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') + Update-FastfetchStatusCache -SkipRefresh + Join-Path $script:TestCacheDir 'updates' | Should -Not -Exist + } + + It "should leave the stamp alone" { + Set-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') -Value 'line' + Update-FastfetchStatusCache -SkipRefresh + Join-Path $script:TestCacheDir '.refreshed-at' | Should -Exist + } + + It "should do nothing when disabled" { + Set-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') -Value 'line' + $env:FASTFETCH_STATUS_DISABLE = '1' + try { + Update-FastfetchStatusCache -SkipRefresh + Join-Path $script:TestCacheDir 'updates' | Should -Not -Exist + } + finally { + $env:FASTFETCH_STATUS_DISABLE = $script:OriginalDisable + } + } + + It "should not throw when the cache directory does not exist" { + Remove-Item -LiteralPath $script:TestCacheDir -Recurse -Force + { Update-FastfetchStatusCache -SkipRefresh } | Should -Not -Throw + } + } + + Context "Start-FastfetchStatusRefresh" { + It "should do nothing when the status script is missing" { + $missing = Join-Path $script:TestCacheDir 'no-such-status.ps1' + { Start-FastfetchStatusRefresh -StatusScript $missing } | Should -Not -Throw + } } } diff --git a/tests/powershell/Profile.Tests.ps1 b/tests/powershell/Profile.Tests.ps1 index 9f3d4496..86a1fcff 100644 --- a/tests/powershell/Profile.Tests.ps1 +++ b/tests/powershell/Profile.Tests.ps1 @@ -454,6 +454,42 @@ Describe "Profile fastfetch startup guard" { # light install (no fastfetch) from erroring on every shell start. $script:ProfileContent | Should -Match 'Get-Command fastfetch -ErrorAction SilentlyContinue' } + + It "Profile should refresh the fastfetch status cache via the module" { + $script:ProfileContent | Should -Match 'Update-FastfetchStatusCache' + } + + It "Profile should guard the status call so a broken cache cannot break the shell" { + $script:ProfileContent | Should -Match 'Get-Command Update-FastfetchStatusCache -ErrorAction SilentlyContinue' + } +} + +Describe "Profile startup noise" { + BeforeAll { + $script:ProfileContent = Get-Content $script:ProfilePath -Raw + } + + It "Profile should record whether fastfetch rendered" { + $script:ProfileContent | Should -Match '\$script:_fastfetchShown = \$false' + $script:ProfileContent | Should -Match '\$script:_fastfetchShown = \$true' + } + + It "Profile should skip the welcome lines when fastfetch rendered" { + # fastfetch already reports the shell and everything else worth knowing, + # so the two extra lines are pure noise underneath its banner. + $script:ProfileContent | Should -Match '-not \$script:_fastfetchShown' + } + + It "Profile should not mark fastfetch as shown when it timed out" { + # A killed fastfetch prints a notice instead of a banner, so the welcome + # lines are still the only confirmation the profile loaded. + $script:ProfileContent | Should -Match 'if \(-not \$fastfetchProc\.WaitForExit' + } + + It "Profile should keep the welcome lines for installs without fastfetch" { + $script:ProfileContent | Should -Match 'PowerShell Profile Loaded' + $script:ProfileContent | Should -Match "Type 'aliases' to see available aliases" + } } Describe "Profile Syntax Validation" { diff --git a/tests/powershell/WindowsTerminal.Tests.ps1 b/tests/powershell/WindowsTerminal.Tests.ps1 index 0a263350..8cbad4d6 100644 --- a/tests/powershell/WindowsTerminal.Tests.ps1 +++ b/tests/powershell/WindowsTerminal.Tests.ps1 @@ -343,6 +343,147 @@ Describe "Set-WindowsTerminalDefaultProfile Function" -Tag "Unit" { } } +Describe "Set-WindowsTerminalProfileCommandLine Function" -Tag "Unit" { + BeforeEach { + $script:settingsPath = Join-Path $script:TestDir.FullName "settings-$(Get-Random).json" + } + + AfterEach { + Remove-Item -LiteralPath $script:settingsPath -ErrorAction SilentlyContinue + } + + BeforeAll { + $script:QuietCommandLine = 'pwsh.exe -NoLogo -NoProfileLoadTime' + $script:CmdLineSettings = @' +{ + "defaultProfile": "{574e775e-4f2a-5b96-ac1e-a2962a402336}", + "profiles": { + "defaults": { "font": { "face": "FiraCode Nerd Font" } }, + "list": [ + { + "commandline": "%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "guid": "{61c54bbd-c2c6-5271-96e7-009a87ff44bf}", + "name": "Windows PowerShell" + }, + { + "guid": "{574e775e-4f2a-5b96-ac1e-a2962a402336}", + "name": "PowerShell", + "source": "Windows.Terminal.PowershellCore" + }, + { + "guid": "{36f9ac1f-0a96-55ed-952d-57a0df08d14f}", + "name": "Debian", + "source": "Microsoft.WSL" + } + ] + } +} +'@ + } + + It "Should support ShouldProcess (WhatIf)" { + (Get-Command Set-WindowsTerminalProfileCommandLine).Parameters.ContainsKey("WhatIf") | Should -Be $true + } + + It "Should add a commandline to the PowerShell Core profile" { + # The dynamic PowerShell profile has no commandline of its own, so this + # is how -NoLogo/-NoProfileLoadTime get applied: they are read before + # any profile script runs and cannot be set from profile.ps1. + $script:CmdLineSettings | Set-Content -LiteralPath $script:settingsPath -Encoding utf8 + + $result = Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $script:settingsPath + + $result.Status | Should -Be 'Updated' + $result.MatchedProfile | Should -Be 'PowerShell' + + $json = Get-Content -LiteralPath $script:settingsPath -Raw | ConvertFrom-Json + $core = @($json.profiles.list) | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' } + $core.commandline | Should -Be $script:QuietCommandLine + } + + It "Should leave the other profiles untouched" { + $script:CmdLineSettings | Set-Content -LiteralPath $script:settingsPath -Encoding utf8 + + Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $script:settingsPath | Out-Null + + $json = Get-Content -LiteralPath $script:settingsPath -Raw | ConvertFrom-Json + $json.profiles.list.Count | Should -Be 3 + @($json.profiles.list)[0].commandline | Should -Be '%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe' + $json.profiles.defaults.font.face | Should -Be 'FiraCode Nerd Font' + $json.defaultProfile | Should -Be '{574e775e-4f2a-5b96-ac1e-a2962a402336}' + } + + It "Should replace an existing commandline" { + $existing = $script:CmdLineSettings -replace '"name": "PowerShell",', '"name": "PowerShell", "commandline": "pwsh.exe",' + $existing | Set-Content -LiteralPath $script:settingsPath -Encoding utf8 + + $result = Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $script:settingsPath + + $result.Status | Should -Be 'Updated' + $json = Get-Content -LiteralPath $script:settingsPath -Raw | ConvertFrom-Json + $core = @($json.profiles.list) | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' } + $core.commandline | Should -Be $script:QuietCommandLine + } + + It "Should be idempotent when the commandline already matches" { + $script:CmdLineSettings | Set-Content -LiteralPath $script:settingsPath -Encoding utf8 + Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $script:settingsPath | Out-Null + + $result = Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $script:settingsPath + + $result.Status | Should -Be 'AlreadySet' + $result.Changed | Should -Be $false + } + + It "Should match a profile by name via -ProfileName" { + $script:CmdLineSettings | Set-Content -LiteralPath $script:settingsPath -Encoding utf8 + + $result = Set-WindowsTerminalProfileCommandLine -ProfileName 'Debian' -CommandLine 'wsl -d Debian' -SettingsPath $script:settingsPath + + $result.Status | Should -Be 'Updated' + $json = Get-Content -LiteralPath $script:settingsPath -Raw | ConvertFrom-Json + $debian = @($json.profiles.list) | Where-Object { $_.name -eq 'Debian' } + $debian.commandline | Should -Be 'wsl -d Debian' + } + + It "Should skip (ProfileNotFound) when PowerShell Core is not installed" { + $noCore = @' +{ + "profiles": { + "list": [ + { "guid": "{61c54bbd-c2c6-5271-96e7-009a87ff44bf}", "name": "Windows PowerShell" } + ] + } +} +'@ + $noCore | Set-Content -LiteralPath $script:settingsPath -Encoding utf8 + + $result = Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $script:settingsPath + + $result.Status | Should -Be 'ProfileNotFound' + $result.Changed | Should -Be $false + } + + It "Should skip paths that do not exist" { + $missing = Join-Path $script:TestDir.FullName "does-not-exist-$(Get-Random).json" + + $result = Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $missing + + $result | Should -BeNullOrEmpty + } + + It "Should not write under -WhatIf" { + $script:CmdLineSettings | Set-Content -LiteralPath $script:settingsPath -Encoding utf8 + + $result = Set-WindowsTerminalProfileCommandLine -CommandLine $script:QuietCommandLine -SettingsPath $script:settingsPath -WhatIf + + $result.Status | Should -Be 'WhatIf' + $json = Get-Content -LiteralPath $script:settingsPath -Raw | ConvertFrom-Json + $core = @($json.profiles.list) | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' } + $core.PSObject.Properties['commandline'] | Should -BeNullOrEmpty + } +} + Describe "Set-WindowsTerminalCopilotProfile Function" -Tag "Unit" { BeforeEach { $script:settingsPath = Join-Path $script:TestDir.FullName "settings-$(Get-Random).json"