Private/Show-TreeInternal.ps1
|
#region Entry Point <# .SYNOPSIS Core recursive engine for Show-Tree. .DESCRIPTION This function renders a directory tree using graphical connectors, optional color, optional file inclusion, and optional gap logic. It is called once from Show-Tree.ps1 and then recursively by itself. Responsibilities: • Normalize and validate the root path • Initialize gap state • Enumerate directories/files (raw Win32 or PowerShell) • Apply filtering (hidden/system) • Render files, directories, and gap lines • Manage recursion depth and prefix construction • Maintain gap-mode state machine (Internal, Tail, Sibling) This function is internal-only and not exported. #> enum GapMode { None Internal Tail Sibling } function Show-TreeInternal { [CmdletBinding()] param ( # Absolute or relative path to render [string]$Path, # Tree.com compatibility mode [switch]$Tree, # Listing mode (indentation only) [switch]$List, # Maximum recursion depth (-1 = unlimited) [int]$MaxDepth, # Enable color output [switch]$Colorize, # Include files in output [switch]$IncludeFiles, # Hide hidden items [switch]$HideHidden, # Hide system items [switch]$HideSystem, # Show reparse point targets [switch]$ShowTargets, # Enable gap logic (blank lines between blocks) [switch]$Gap, # Use ASCII connectors instead of Unicode [switch]$Ascii, # Show attribute debug info [switch]$DebugAttributes, # Current recursion depth (internal) [int]$CurrentDepth = 0, # Prefix string built from parent connectors [string]$Prefix = "", # Whether the parent directory was the last sibling [bool]$IsLastParent = $false ) # Precompute ANSI sequences $esc = [char]27 $colorReset = $Colorize ? "${esc}[0m" : "" $colorGap = $Colorize ? "${esc}[90m" : "" # Precompute gap connector once per directory block $gapConnector = Get-Connector -Type Gap -Tree:$Tree -List:$List -Ascii:$Ascii # # Root-level initialization # if ($CurrentDepth -eq 0) { # Initialize gap state machine $script:GapState = [PSCustomObject]@{ LastGapMode = [GapMode]::None } # Normalize path casing and separators $Path = Get-NormalizedPath -Path $Path # Validate existence if (-not (Test-Path $Path)) { $invalidPath = $true } # Tree.com header if ($Tree) { $nearestExistingParent = Get-NearestExistingParent -Path $Path $fileSystemLabel = Get-VolumeName -Path $nearestExistingParent $serialNumber = Get-VolumeSerialNumber -Path $nearestExistingParent Write-Output "Folder PATH listing for volume $fileSystemLabel" Write-Output "Volume serial number is $serialNumber" } # Render root directory name $root = Get-Item $Path $dir = [PSCustomObject]@{ FullName = $root.FullName Name = $root.Name Attributes = $root.Attributes PSIsContainer = $true } $dir.PSObject.TypeNames.Insert(0, 'System.IO.DirectoryInfo') $style = Get-ItemStyle -Item $dir -Colorize:$Colorize # Optional attribute debug if ($DebugAttributes) { $styleName = $style.Name $attrHex = ('0x{0:X8}' -f [uint32]$dir.Attributes) $attrNames = $dir.Attributes.ToString() $debug = " [$attrHex $attrNames | $styleName]" } Write-Output "$($style.Ansi)$Path${colorReset}${debug}" if ($invalidPath) { Write-Output "Invalid path - \$($Path -Split '\\' | Select-Object -Last 1)" } } # # Depth cap enforcement # if ($MaxDepth -ne -1 -and $CurrentDepth -ge $MaxDepth) { return } # # Directory enumeration # if ($Tree) { # Raw Win32 enumeration for Tree.com compatibility $raw = Get-RawDirectoryEntries -Path $Path $dirs = $raw.Directories $files = $IncludeFiles ? $raw.Files : @() } else { # Standard PowerShell enumeration $dirs = Get-ChildItem -Path $Path -Directory -Force -ErrorAction SilentlyContinue $files = $IncludeFiles ? (Get-ChildItem -Path $Path -File -Force -ErrorAction SilentlyContinue) : @() } # # Filtering # if ($HideHidden) { $dirs = $dirs | Where-Object { -not ($_.Attributes -band [IO.FileAttributes]::Hidden) } $files = $files | Where-Object { -not ($_.Attributes -band [IO.FileAttributes]::Hidden) } } if ($HideSystem) { $dirs = $dirs | Where-Object { -not ($_.Attributes -band [IO.FileAttributes]::System) } $files = $files | Where-Object { -not ($_.Attributes -band [IO.FileAttributes]::System) } } $fileCount = $files.Count $dirCount = $dirs.Count # Tree.com: suppress file connectors when no subdirectories exist $noSpan = $Tree -and $dirCount -eq 0 # # FILE RENDERING # for ($j = 0; $j -lt $fileCount; $j++) { $file = $files[$j] $isLastFile = ($j -eq $fileCount - 1) -and ($dirCount -eq 0) Write-TreeItem ` -Item $file ` -Type File ` -Prefix $Prefix ` -IsLast $isLastFile ` -Tree:$Tree ` -List:$List ` -Ascii:$Ascii ` -Colorize:$Colorize ` -ShowTargets:$ShowTargets ` -DebugAttributes:$DebugAttributes ` -Recurse:$false ` -NoSpan $noSpan ` -MaxDepth $MaxDepth ` -CurrentDepth $CurrentDepth ` -IncludeFiles:$IncludeFiles ` -Gap:$Gap ` -HideHidden:$HideHidden ` -HideSystem:$HideSystem } # # Early exit for empty directories # if ($invalidPath -or ($CurrentDepth -eq 0 -and $dirCount -eq 0)) { if ($Tree) { if (-not $invalidPath) { Write-Output "" } Write-Output "No subfolders exist" } Write-Output "" return } # # INTERNAL GAP (files → directories) # if ($Gap -and $script:GapState.LastGapMode -eq [GapMode]::None -and $IncludeFiles -and $fileCount -gt 0) { if ($dirCount -gt 0) { # Files + directories → connector gap Write-Gap $colorGap $Prefix $gapConnector $colorReset ([GapMode]::Internal) } else { # Files only → tail gap if ($Tree -or (-not $IsLastParent)) { Write-Gap $colorGap $Prefix $null $colorReset ([GapMode]::Tail) } } } # # DIRECTORY RENDERING # for ($i = 0; $i -lt $dirCount; $i++) { $dir = $dirs[$i] $isLastDir = ($i -eq $dirCount - 1) Write-TreeItem ` -Item $dir ` -Type Directory ` -Prefix $Prefix ` -IsLast $isLastDir ` -Tree:$Tree ` -List:$List ` -Ascii:$Ascii ` -Colorize:$Colorize ` -ShowTargets:$ShowTargets ` -DebugAttributes:$DebugAttributes ` -Recurse ` -NoSpan $false ` -MaxDepth $MaxDepth ` -CurrentDepth $CurrentDepth ` -IncludeFiles:$IncludeFiles ` -Gap:$Gap ` -HideHidden:$HideHidden ` -HideSystem:$HideSystem # # SIBLING / COUSIN GAP LOGIC # if ($Gap -and $i -lt $dirCount - 1) { # Tail gap suppresses immediate sibling gap if ($script:GapState.LastGapMode -eq [GapMode]::Tail) { $script:GapState.LastGapMode = [GapMode]::None continue } # Prevent consecutive gaps if ($script:GapState.LastGapMode -ne [GapMode]::None) { continue } # Normal mode: only if left sibling has visible children if (-not $Tree) { if (Test-HasChildrenForGap -Dir $dirs[$i] -CurrentDepth $CurrentDepth -MaxDepth $MaxDepth) { Write-Gap $colorGap $Prefix $gapConnector $colorReset ([GapMode]::Sibling) } } } } # # Final newline for normal mode root # if ($CurrentDepth -eq 0 -and -not $Tree) { Write-Output "" } } #endregion #region Rendering (TreeItem, Gap) <# .SYNOPSIS Renders a single file or directory entry. .DESCRIPTION Handles: • Connector selection • Style/color application • Reparse target display • Attribute debug output • Recursion into subdirectories • Gap-state reset #> function Write-TreeItem { param( [Parameter(Mandatory)] $Item, [Parameter(Mandatory)] [ValidateSet('File','Directory')] [string]$Type, # Prefix inherited from parent [string]$Prefix = "", # Whether this item is the last sibling [bool]$IsLast, # Mode switches [switch]$Tree, [switch]$List, [switch]$Ascii, [switch]$Colorize, [switch]$ShowTargets, [switch]$DebugAttributes, [switch]$Recurse, # Whether to suppress file connector span [bool]$NoSpan = $false, # Recursion state [int]$MaxDepth, [int]$CurrentDepth, # Additional flags [switch]$IncludeFiles, [switch]$Gap, [switch]$HideHidden, [switch]$HideSystem ) # Compute connector for this item $connector = Get-Connector ` -Type $Type ` -Tree:$Tree ` -List:$List ` -Ascii:$Ascii ` -IsLast $IsLast ` -NoSpan $NoSpan # Compute style $style = Get-ItemStyle -Item $Item -Colorize:$Colorize # # Reparse target resolution # $target = $null if ($ShowTargets -and ($Item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { $info = Get-Item -LiteralPath $Item.FullName -Force -ErrorAction SilentlyContinue if ($info -and $info.PSObject.Properties.Match('Target')) { $target = $info.Target } } # # Output formatting # $esc = [char]27 $reset = $Colorize ? "${esc}[0m" : "" $dim = $Colorize ? "${esc}[90m" : "" $targetText = $target ? " ${dim}->${reset} $target" : "" # Optional attribute debug $debug = "" if ($DebugAttributes) { $styleName = $style.Name $attrHex = ('0x{0:X8}' -f [uint32]$Item.Attributes) $attrNames = $Item.Attributes.ToString() $debug = " [$attrHex $attrNames | $styleName]" } Write-Output "${dim}${Prefix}${dim}${connector}$($style.Ansi)$($Item.Name)$reset$targetText $debug" # # Reset gap state unless tail gap was printed # if ($script:GapState.LastGapMode -ne [GapMode]::Tail) { $script:GapState.LastGapMode = [GapMode]::None } # # Recursion into subdirectories # if ($Recurse -and $Type -eq 'Directory' -and -not ($Item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { # Build next-level prefix $newPrefix = $Prefix + (Get-Connector ` -Type Prefix ` -Tree:$Tree ` -List:$List ` -Ascii:$Ascii ` -IsLast $IsLast) # Recurse Show-TreeInternal ` -Path $Item.FullName ` -Tree:$Tree ` -List:$List ` -MaxDepth $MaxDepth ` -Colorize:$Colorize ` -IncludeFiles:$IncludeFiles ` -HideHidden:$HideHidden ` -HideSystem:$HideSystem ` -ShowTargets:$ShowTargets ` -Gap:$Gap ` -Ascii:$Ascii ` -DebugAttributes:$DebugAttributes ` -CurrentDepth ($CurrentDepth + 1) ` -Prefix $newPrefix ` -IsLastParent $IsLast } # # Reset gap state again after recursion # if ($script:GapState.LastGapMode -ne [GapMode]::Tail) { $script:GapState.LastGapMode = [GapMode]::None } } <# .SYNOPSIS Writes a gap line between blocks. .DESCRIPTION Handles Internal, Tail, and Sibling gap modes. Updates the global gap-state machine. #> function Write-Gap { param( $colorGap, $Prefix, $GapConnector, $colorReset, [GapMode]$Mode ) $connector = $GapConnector ? $GapConnector : "" Write-Output "${colorGap}${Prefix}${connector}${colorReset}" $script:GapState.LastGapMode = $Mode } #endregion #region Gap Logic Helpers <# .SYNOPSIS Determines whether a directory has visible children. .DESCRIPTION Used to decide whether to print a sibling/cousin gap. Respects MaxDepth and treats reparse points as leaf nodes. #> function Test-HasChildrenForGap { param( $Dir, [int]$CurrentDepth, [int]$MaxDepth ) if (Test-IsReparsePoint $Dir) { return $false } # Depth cap: treat as empty if recursion would stop here if ($MaxDepth -ne -1 -and $CurrentDepth + 1 -ge $MaxDepth) { return $false } $children = Get-ChildItem -LiteralPath $Dir.FullName -Force -ErrorAction SilentlyContinue return $children.Count -gt 0 } <# .SYNOPSIS Checks whether an item is a reparse point. .DESCRIPTION Reparse points (symlinks/junctions) are treated as leaf nodes for recursion and gap logic. #> function Test-IsReparsePoint { param($Item) [bool]($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) } #endregion #region Connector Rendering <# .SYNOPSIS Returns the connector string for a given item type. .DESCRIPTION Handles: • Tree.com ASCII mode • Unicode graphical mode • Prefix vs File vs Directory vs Gap • Last-sibling logic • NoSpan suppression for Tree.com file connectors #> function Get-Connector { param( [Parameter(Mandatory)] [ValidateSet('File','Directory','Gap','Prefix')] [string]$Type, [switch]$Tree, [switch]$List, [switch]$Ascii, [bool]$IsLast = $false, [bool]$NoSpan = $false ) # # Listing mode: indentation only # if ($List) { return ' ' } # # Tree.com compatibility mode # if ($Tree) { if ($Type -eq 'File' -and $NoSpan) { return ' ' } switch ($Type) { 'File' { return $Ascii ? '| ' : '│ ' } 'Directory' { if ($IsLast) { return $Ascii ? '\---' : '└───' } else { return $Ascii ? '+---' : '├───' } } 'Gap' { return $Ascii ? '|' : '│' } 'Prefix' { if ($IsLast) { return ' ' } else { return $Ascii ? '| ' : '│ ' } } } } # # Graphical Unicode mode (Show‑Tree default) # switch ($Type) { 'File' { if ($IsLast) { return $Ascii ? '\-- ' : '╙── ' } else { return $Ascii ? '+-- ' : '╟── ' } } 'Directory' { if ($IsLast) { return $Ascii ? '\== ' : '╚══ ' } else { return $Ascii ? '+== ' : '╠══ ' } } 'Gap' { return $Ascii ? '|' : '║' } 'Prefix' { if ($IsLast) { return ' ' } else { return $Ascii ? '| ' : '║ ' } } } } #endregion #region Style Rendering <# .SYNOPSIS Computes the ANSI style for a file or directory. .DESCRIPTION Applies: • Base style (directory/file/symlink/junction) • Attribute overlays (hidden, system, temporary, etc.) • Foreground overrides • Combined ANSI escape sequence Returns an object: @{ Name = "..."; Ansi = "..."; } #> function Get-ItemStyle { param( $Item, $Colorize = $script:Colorize, $StyleProfile = $script:StyleProfile ) $esc = [char]27 $isDir = $Item.PSIsContainer $attrs = $Item.Attributes $isReparse = [bool]($attrs -band [IO.FileAttributes]::ReparsePoint) # # Determine base style # if ($isReparse -and $isDir) { $styleName = "Junction" $base = $StyleProfile.Base.Junction } elseif ($isReparse -and -not $isDir) { $styleName = "Symlink" $base = $StyleProfile.Base.File } elseif ($isDir) { $styleName = "Directory" $base = $StyleProfile.Base.Directory } else { $styleName = "File" $base = $StyleProfile.Base.File } # # No color mode # if (-not $Colorize) { return [PSCustomObject]@{ Name = $styleName Ansi = "" } } # # Parse base style codes # $codes = @() + ($base -split ';' | Where-Object { $_ -ne '' }) # Extract foreground codes (30–37, 90–97) $fg = @() + ($codes | Where-Object { $_ -match '^(3[0-7]|9[0-7])$' }) $codes = @() + ($codes | Where-Object { $_ -notmatch '^(3[0-7]|9[0-7])$' }) # # Apply attribute overlays # foreach ($flag in Get-SetFileAttributes $attrs) { $flagName = $flag.ToString() if ($StyleProfile.Attributes.ContainsKey($flagName)) { $overlay = $StyleProfile.Attributes[$flagName] # Add overlay attributes if ($overlay.Attributes) { $codes += ($overlay.Attributes -split ';') } # Foreground override if ($overlay.OverrideForeground) { if ($overlay.OverrideForeground -is [string]) { $fg = $overlay.OverrideForeground } elseif ($overlay.OverrideForeground.ContainsKey($styleName)) { $fg = $overlay.OverrideForeground[$styleName] } } } } # # Build final ANSI sequence # $final = @() if ($fg) { $final += $fg } $final += $codes $ansi = "${esc}[$($final -join ';')m" [PSCustomObject]@{ Name = $styleName Ansi = $ansi } } #endregion #region Path Utilities <# .SYNOPSIS Enumerates all set file attributes on an item. .DESCRIPTION Used by Get-ItemStyle to apply attribute overlays. #> function Get-SetFileAttributes { param([IO.FileAttributes]$Attributes) foreach ($flag in [System.Enum]::GetValues([IO.FileAttributes])) { if ($Attributes -band $flag) { $flag } } } <# .SYNOPSIS Normalizes a path to match actual filesystem casing. .DESCRIPTION Walks each segment and resolves its real casing using Get-ChildItem. Ensures consistent display even when user input is lowercase/mixed. #> function Get-NormalizedPath { param([string]$Path = ".") $absPath = [System.IO.Path]::GetFullPath($Path) # Trim trailing slash unless root if ($absPath.Length -gt 3 -and $absPath.EndsWith("\")) { $absPath = $absPath.TrimEnd('\') } $segments = $absPath -split '\\' $normalized = @() $current = $segments[0] + "\" $normalized += $segments[0] for ($i = 1; $i -lt $segments.Count; $i++) { $segment = $segments[$i] try { $entries = Get-ChildItem -LiteralPath $current | Select-Object -ExpandProperty Name $match = $entries | Where-Object { $_.ToLower() -eq $segment.ToLower() } if ($match) { $normalized += $match $current = Join-Path $current $match } else { $normalized += $segment $current = Join-Path $current $segment } } catch { # Parent doesn't exist — keep original casing $normalized += $segment $current = Join-Path $current $segment } } ($normalized -join '\') } <# .SYNOPSIS Finds the nearest existing parent directory. .DESCRIPTION Used for Tree.com header generation when the target path does not fully exist. #> function Get-NearestExistingParent { param([string]$Path) $current = [System.IO.Path]::GetFullPath($Path) while (-not (Test-Path $current)) { $parent = [System.IO.Directory]::GetParent($current) if ($null -eq $parent) { return $null } $current = $parent.FullName } $current } <# .SYNOPSIS Returns the filesystem label for a drive. .DESCRIPTION Used only in Tree.com compatibility mode. #> function Get-VolumeName { param([string]$Path = ".") $driveLetter = (Get-Item $Path).PSDrive.Name $volume = Get-Volume -DriveLetter $driveLetter $volume.FileSystemLabel } <# .SYNOPSIS Retrieves the volume serial number using Win32 API. .DESCRIPTION Matches Tree.com output exactly. #> function Get-VolumeSerialNumber { param ( [string]$Path = "." ) if (-not ([System.Management.Automation.PSTypeName]'VolumeInfo').Type) { $definition = @" using System; using System.Runtime.InteropServices; public class VolumeInfo { [DllImport("kernel32.dll", CharSet = CharSet.Auto)] public static extern bool GetVolumeInformation( string lpRootPathName, System.Text.StringBuilder lpVolumeNameBuffer, int nVolumeNameSize, out uint lpVolumeSerialNumber, out uint lpMaximumComponentLength, out uint lpFileSystemFlags, System.Text.StringBuilder lpFileSystemNameBuffer, int nFileSystemNameSize); } "@ Add-Type -TypeDefinition $definition -ErrorAction SilentlyContinue | Out-Null } $root = [System.IO.Path]::GetPathRoot((Resolve-Path $Path).Path) $serial = 0 $null1 = 0 $null2 = 0 $volName = New-Object System.Text.StringBuilder 261 $fsName = New-Object System.Text.StringBuilder 261 [VolumeInfo]::GetVolumeInformation( $root, $volName, $volName.Capacity, [ref]$serial, [ref]$null1, [ref]$null2, $fsName, $fsName.Capacity ) | Out-Null $serialHigh = ($serial -shr 16) $serialLow = ($serial -band 0xFFFF) "{0:X4}-{1:X4}" -f $serialHigh, $serialLow } #endregion #region Raw Directory Enumeration <# .SYNOPSIS Enumerates directory entries using Win32 FindFirstFile. .DESCRIPTION Used in Tree.com mode to match exact ordering and behavior. Returns PSCustomObject with: • Directories = [...] • Files = [...] #> function Get-RawDirectoryEntries { param([string]$Path) # # Load RawEnum type once # if (-not ([System.Management.Automation.PSTypeName]'RawEnum').Type) { $definition = @" using System; using System.IO; using System.Runtime.InteropServices; using System.Collections.Generic; public class RawEnum { [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct WIN32_FIND_DATA { public uint dwFileAttributes; public System.Runtime.InteropServices.ComTypes.FILETIME ftCreationTime; public System.Runtime.InteropServices.ComTypes.FILETIME ftLastAccessTime; public System.Runtime.InteropServices.ComTypes.FILETIME ftLastWriteTime; public uint nFileSizeHigh; public uint nFileSizeLow; public uint dwReserved0; public uint dwReserved1; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string cFileName; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)] public string cAlternateFileName; } [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern IntPtr FindFirstFile(string lpFileName, out WIN32_FIND_DATA lpFindFileData); [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern bool FindNextFile(IntPtr hFindFile, out WIN32_FIND_DATA lpFindFileData); [DllImport("kernel32.dll")] static extern bool FindClose(IntPtr hFindFile); public static IEnumerable<WIN32_FIND_DATA> Enum(string path) { WIN32_FIND_DATA data; IntPtr handle = FindFirstFile(Path.Combine(path, "*"), out data); if (handle == new IntPtr(-1)) yield break; do { string name = data.cFileName; if (name != "." && name != "..") yield return data; } while (FindNextFile(handle, out data)); FindClose(handle); } } "@ Add-Type -TypeDefinition $definition -ErrorAction SilentlyContinue | Out-Null } # # Enumerate entries # $entries = [RawEnum]::Enum($Path) $dirs = @() $files = @() foreach ($e in $entries) { $isDir = ($e.dwFileAttributes -band [IO.FileAttributes]::Directory) -ne 0 $root = Get-Item (Join-Path $Path $e.cFileName) -Force $item = [PSCustomObject]@{ FullName = $root.FullName Name = $root.Name Attributes = $root.Attributes PSIsContainer = $isDir } if ($isDir) { $item.PSObject.TypeNames.Insert(0, 'System.IO.DirectoryInfo') $dirs += $item } else { $item.PSObject.TypeNames.Insert(0, 'System.IO.FileInfo') $files += $item } } # # Return structured result # [PSCustomObject]@{ Directories = $dirs Files = $files } } #endregion |