Private/DataStore.ps1
|
# --------------------------------------------------------------------------- # Data store - JSON helpers, initialization, deduplication # --------------------------------------------------------------------------- function Initialize-TTDataStore { if (-not (Test-Path $script:DataRoot)) { New-Item -Path $script:DataRoot -ItemType Directory -Force | Out-Null } foreach ($f in @($script:SessionsFile, $script:ArchiveFile, $script:SuspendedFile)) { if (-not (Test-Path $f)) { '[]' | Set-Content -Path $f -Encoding UTF8 } } if (-not (Test-Path $script:ConfigFile)) { Get-TTDefaultConfig | ConvertTo-Json -Depth 5 | Set-Content -Path $script:ConfigFile -Encoding UTF8 } } function Get-TTDefaultConfig { [PSCustomObject]@{ ArchiveRetentionDays = 30 MaxArchiveEntries = 500 MonitorIntervalSeconds = 10 AutoStart = $false AutoReload = $false SyncPath = $null ProfileHookInstalled = $false Notifications = [PSCustomObject]@{ NewTerminal = $true SessionEnded = $true SuspendResume = $true } TerminalProfiles = @( [PSCustomObject]@{ Name = 'PowerShell 7'; Process = 'pwsh' } [PSCustomObject]@{ Name = 'Windows PowerShell'; Process = 'powershell' } [PSCustomObject]@{ Name = 'Command Prompt'; Process = 'cmd' } [PSCustomObject]@{ Name = 'Git Bash'; Process = 'bash'; ParentProcess = 'git-bash' } [PSCustomObject]@{ Name = 'Windows Terminal'; Process = 'WindowsTerminal' } ) } } # --------------------------------------------------------------------------- # File locking - mutex-based coordination across prompt hook, monitor, CLI # --------------------------------------------------------------------------- function Invoke-WithFileLock { <# .SYNOPSIS Acquire a system-wide named mutex before executing an action. .DESCRIPTION Coordinates concurrent JSON writes from the prompt hook, background monitor job, and direct CLI invocations. .PARAMETER Action The scriptblock to execute while holding the lock. .PARAMETER MutexName System-wide mutex name (Global\ prefix spans sessions). #> [CmdletBinding()] param( [Parameter(Mandatory)] [scriptblock]$Action, [string]$MutexName = 'Global\TerminalTracker-DataLock' ) $mutex = $null $acquired = $false try { $mutex = [System.Threading.Mutex]::new($false, $MutexName) try { $acquired = $mutex.WaitOne(2000) } catch [System.Threading.AbandonedMutexException] { $acquired = $true } if (-not $acquired) { Write-Warning 'TerminalTracker: data lock timeout - write skipped.' return } & $Action } finally { if ($acquired) { try { $mutex.ReleaseMutex() } catch { Write-Verbose "Mutex release failed: $_" } } if ($null -ne $mutex) { $mutex.Dispose() } } } # --------------------------------------------------------------------------- # JSON helpers (file-locking safe) # --------------------------------------------------------------------------- function ConvertTo-NormalizedSessionList { <# .SYNOPSIS Flatten PS serialization wrappers and deduplicate by Id. .DESCRIPTION Shared logic for Read-JsonFile and Write-JsonFile to normalize arrays that may contain PS5.1 serialization wrappers and duplicate entries. #> param($InputData) $flat = [System.Collections.Generic.List[object]]::new() foreach ($item in @($InputData)) { if ($null -eq $item) { continue } # Detect PS serialization wrapper: has 'value' + 'Count' but no 'Id' if ($item.PSObject.Properties['value'] -and $item.PSObject.Properties['Count'] -and -not $item.PSObject.Properties['Id']) { foreach ($inner in @($item.value)) { if ($null -ne $inner) { $flat.Add($inner) } } } else { $flat.Add($item) } } # Deduplicate by Id (keep latest by LastUpdated) $seen = @{} $deduped = [System.Collections.Generic.List[object]]::new() foreach ($item in $flat) { $key = if ($item.PSObject.Properties['Id']) { $item.Id } else { $null } if (-not $key) { $deduped.Add($item); continue } if ($seen.ContainsKey($key)) { $existingIdx = $seen[$key] $existing = $deduped[$existingIdx] if ($item.LastUpdated -gt $existing.LastUpdated) { $deduped[$existingIdx] = $item } } else { $seen[$key] = $deduped.Count $deduped.Add($item) } } return ,$deduped } function Read-JsonFile { param([string]$Path) if (-not (Test-Path $Path)) { return ,@() } $raw = Get-Content -Path $Path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue if ([string]::IsNullOrWhiteSpace($raw)) { return ,@() } try { # Use -InputObject to avoid PS5.1 pipeline unwrapping issues $parsed = ConvertFrom-Json -InputObject $raw $deduped = ConvertTo-NormalizedSessionList $parsed # Return with comma operator to prevent pipeline unrolling in PS5.1 return $deduped.ToArray() } catch { Write-Verbose "Failed to parse JSON from $Path : $_" return @() } } function Write-JsonFile { param([string]$Path, $Data) $deduped = ConvertTo-NormalizedSessionList $Data # Use ConvertTo-Json -InputObject (NOT pipeline) for PS5.1 compatibility # Pipeline input in PS5.1 can produce inconsistent results $arr = $deduped.ToArray() if ($arr.Count -eq 0) { $json = '[]' } else { $json = ConvertTo-Json -InputObject $arr -Depth 10 } Invoke-WithFileLock -Action { [System.IO.File]::WriteAllText($Path, $json, [System.Text.Encoding]::UTF8) } } function ConvertTo-FlatArray { <# Ensure a function return is a flat array, safe for PS5.1 which can nest arrays. #> param($Input_) $result = [System.Collections.Generic.List[object]]::new() foreach ($item in @($Input_)) { if ($null -eq $item) { continue } if ($item -is [System.Collections.IEnumerable] -and $item -isnot [string] -and $item -isnot [System.Collections.IDictionary]) { # Item is itself an array/list - flatten one level foreach ($inner in $item) { if ($null -ne $inner) { $result.Add($inner) } } } else { $result.Add($item) } } $result.ToArray() } # --------------------------------------------------------------------------- # Quick Launch helpers # --------------------------------------------------------------------------- function Get-TTRecentCwds { <# .SYNOPSIS Get recent unique working directories from sessions, archive, and suspended. .DESCRIPTION Collects CWDs from active sessions, archived sessions, and suspended sessions, deduplicates them, and returns up to $Count unique paths. Used by the tray Quick Launch submenu. .PARAMETER Count Maximum number of CWDs to return. Default: 10. #> [CmdletBinding()] param( [int]$Count = 10 ) $active = ConvertTo-FlatArray (Get-TTSession) | Select-Object -ExpandProperty WorkingDirectory $archive = ConvertTo-FlatArray (Get-TTArchive) | Select-Object -ExpandProperty WorkingDirectory $suspended = ConvertTo-FlatArray (Get-TTSuspended) | Select-Object -ExpandProperty WorkingDirectory $all = @($active) + @($archive) + @($suspended) $all | Where-Object { $_ -and (Test-Path $_ -ErrorAction SilentlyContinue) } | Select-Object -Unique | Select-Object -First $Count } |