PSDirectoryTree.psm1
# Licensed under the MIT License. See LICENSE.txt for details. function EscapeExceptCharacterClasses([string]$pattern) { $result = "" $inCharClass = $false foreach ($c in $pattern.ToCharArray()) { if ($c -eq '[') { $inCharClass = $true $result += '[' } elseif ($c -eq ']') { $inCharClass = $false $result += ']' } elseif ($inCharClass) { $result += $c } else { switch ($c) { '*' { $result += '\*' } '?' { $result += '\?' } '.' { $result += '\.' } '/' { $result += '/' } '\' { $result += '\\' } default { $result += [Regex]::Escape($c) } } } } return $result } function Normalize-PathUnixStyle { param ( [string]$Path ) if ($null -eq $script:UnixPathMap) { $script:UnixPathMap = @{} } if ($script:UnixPathMap.ContainsKey($Path)) { return $script:UnixPathMap[$Path] } $UnixPath = $Path -replace "\\", "/" if ($IsLinux -or $IsMacOS) { if (-not $UnixPath.StartsWith('/')) { $UnixPath = '/' + $UnixPath.TrimStart('/') } } if (-not $script:UnixPathMap.ContainsKey($Path)) { $script:UnixPathMap[$Path] = $UnixPath } return $UnixPath } function Convert-GitIgnorePatternToRegex { param ( [string]$Pattern ) if ($null -eq $script:RegexMap) { $script:RegexMap = @{} } if ($script:RegexMap.ContainsKey($Pattern)) { Write-Verbose "Using cached regex for pattern '$Pattern' = $($script:RegexMap[$Pattern].RegexString)" return $script:RegexMap[$Pattern] } $pattern = $Pattern.Trim() $isDirPattern = $pattern.EndsWith("/") $pattern = $pattern.TrimEnd('/') $isNegation = $pattern.StartsWith("!") if ($isNegation) { $pattern = $pattern.Substring(1) } $escaped = EscapeExceptCharacterClasses $pattern $escaped = $escaped -replace "\\\*\\\*\/", "(.*\/)?" $escaped = $escaped -replace "\\\*", "[^\/]*" $escaped = $escaped -replace "\\\?", "." $escaped = $escaped -replace "\\\.", "\." if ($pattern.StartsWith("/")) { $escaped = "\" + $escaped } else { $escaped = ".*" + $escaped } $regex = "$escaped$" if ($isDirPattern) { $regex = "$escaped(\/.*)?$" } $script:RegexMap[$Pattern] = [PSCustomObject]@{ IsNegation = $isNegation RegexString = $regex } return $script:RegexMap[$Pattern] } function Should-SkipPath { param ( [string]$Path, [hashtable]$IgnoreMap ) $pathUnix = Normalize-PathUnixStyle $Path $allRules = @() $allKeys = @() foreach ($key in $IgnoreMap.Keys) { $keyUnix = Normalize-PathUnixStyle $key if ($pathUnix.StartsWith($keyUnix)) { foreach ($rule in $IgnoreMap[$key]) { $allRules += $rule $allKeys += $keyUnix.Replace("/", "\/") } } } $isIgnored = $false for ($i = 0; $i -lt $allRules.Count; $i++) { $pattern = $allRules[$i] $patternInfo = Convert-GitIgnorePatternToRegex $pattern $gitIgnorePath = $allKeys[$i] $regex = $gitIgnorePath + $patternInfo.RegexString if ($pathUnix -match $regex) { if ($patternInfo.IsNegation) { Write-Verbose "UNIGNORED '$pathUnix' by rule '$pattern' in '$gitIgnorePath'" $isIgnored = $false } else { Write-Verbose "IGNORED '$pathUnix' by rule '$pattern' in '$gitIgnorePath'" $isIgnored = $true } } } return $isIgnored } function Get-Structure { param( [string]$Path, [string]$Indent = "", [bool]$IsLast = $true, [int]$CurrentDepth = 0, [int]$MaxDepth, [string]$Filter, [switch]$AsObject, [switch]$IgnoreGitIgnore, [hashtable]$IgnoreMap = @{} ) $Item = Get-Item -LiteralPath $Path -Force:$IsLinux Write-Verbose "Visiting: $($Item.FullName) (Depth=$CurrentDepth)" if ($item.PSIsContainer) { $script:progressDirectories++ Write-Progress -Activity "Scanning directories..." -Status "$($script:progressDirectories.ToString().PadLeft(5, '0')) $($Item.FullName)" } else { $script:progressFiles++ } $currentIgnoreRules = @() $gitignore = Join-Path $Path '.gitignore' if (-not $IgnoreGitIgnore -and (Test-Path $gitignore -PathType Leaf)) { $currentIgnoreRules = Get-Content $gitignore | Where-Object { $_ -and -not $_.StartsWith("#") } | ForEach-Object { $_.Trim().TrimEnd('/','\') } $normalizedPath = Normalize-PathUnixStyle $Path if (-not $IgnoreMap.ContainsKey($normalizedPath)) { $IgnoreMap[$normalizedPath] = @('.git') + $currentIgnoreRules Write-Verbose "Loaded .gitignore from '$gitignore' with $($currentIgnoreRules.Count) rule(s)" foreach ($rule in $currentIgnoreRules) { Write-Verbose " Rule: '$rule' (attached to '$normalizedPath')" } } } if ($AsObject) { $node = [PSCustomObject]@{ Name = $Item.Name Path = $Item.FullName Type = if ($Item.PSIsContainer) { "Folder" } else { "File" } Children = @() } } else { $connector = if ($CurrentDepth -eq 0 -and $Item.PSIsContainer) { "" } elseif ($IsLast) { "└──" } else { "├──" } $displayIndent = $Indent + $connector $lines = @( [PSCustomObject]@{ Line = "$displayIndent " IsDir = $Item.PSIsContainer Name = $Item.Name Path = $Item.FullName } ) if ($CurrentDepth -eq 0 -and $Item.PSIsContainer) { $Indent = " " } elseif ($IsLast) { $Indent += " " } else { $Indent += "│ " } } if ($Item.PSIsContainer -and $CurrentDepth -lt $MaxDepth -and -not ($Item.Attributes -match "ReparsePoint")) { $Children = @() if ($IgnoreGitIgnore) { $Children = @(Get-ChildItem -LiteralPath $Path -Force:$IsLinux -ErrorAction SilentlyContinue | Sort-Object -Property PSIsContainer, Name) } else { $Children = @(Get-ChildItem -LiteralPath $Path -Force:$IsLinux -ErrorAction SilentlyContinue | ForEach-Object { if (-not (Should-SkipPath -Path $_.FullName -IgnoreMap $IgnoreMap)) { Write-Verbose "Included: $($_.FullName)" $_ } else { Write-Verbose "Skipped by .gitignore: $($_.FullName)" } } | Sort-Object -Property PSIsContainer, Name) } $Folders = @($Children | Where-Object { $_.PSIsContainer }) $Files = @($Children | Where-Object { -not $_.PSIsContainer }) switch ($Filter) { "All" { $OrderedChildren = $Folders + $Files } "Folders" { $OrderedChildren = $Folders } "Files" { $OrderedChildren = $Files } } for ($i = 0; $i -lt $OrderedChildren.Count; $i++) { $child = $OrderedChildren[$i] $isLastChild = ($i -eq $OrderedChildren.Count - 1) $nextDepth = if ($child.PSIsContainer) { $CurrentDepth + 1 } else { $CurrentDepth } $result = Get-Structure -Path $child.FullName -Indent $Indent -IsLast $isLastChild -CurrentDepth $nextDepth -MaxDepth $MaxDepth -Filter $Filter -IgnoreGitIgnore:$IgnoreGitIgnore -AsObject:$AsObject -IgnoreMap $IgnoreMap if ($AsObject) { $node.Children += $result } else { $lines += $result } } } if ($AsObject) { return $node } else { return $lines } } function Get-TreeOutput { param( [PSCustomObject[]]$tree, [switch]$AsList ) $list = @() foreach ($entry in $tree) { if ($AsList) { $list += "$($entry.Line)$($entry.Name)"; } else { $color = if ($entry.IsDir) { 'Cyan' } else { switch -Wildcard ("$($entry.Name)") { "*.html" { 'Magenta'; break; } "*.ts" { 'Blue'; break; } "*.json" { 'DarkYellow'; break; } "*.js" { 'Yellow'; break; } "*.*css" { 'Green'; break; } default { 'Gray' } } } Write-Host $entry.Line -NoNewline Write-Host $entry.Name -ForegroundColor $color } } if ($AsList) { return $list } } function Get-DirectoryTree { <# .SYNOPSIS ┌─────── PSDirectoryTree ───────┐ │🧾 Directory visualizing tool📁│ └───────────────────────────────┘ Visualizes a directory's folder and file structure in a tree-like format. .DESCRIPTION This tool recursively displays a tree of a given root directory. It supports filtering to show only files, only folders, or both. It reads and applies .gitignore rules (per folder) if present, using glob-like matching logic to skip ignored paths. The tool provides: - Terminal output with Unicode tree characters and color-coded file types - JSON serialization of the directory structure - Optional output to a file in either text or JSON format - Depth limiting and optional ignore skipping .PARAMETER RootPath The root folder to scan. Accepts relative or absolute paths. .PARAMETER MaxDepth Controls how deep to recurse. Default is 100. .PARAMETER Filter Chooses which items to show: - All : both files and folders (default) - Folders : folders only - Files : files only .PARAMETER IgnoreGitIgnore If set, skips loading .gitignore patterns. If not set, rules are loaded and applied per directory. .PARAMETER AsJson If set, outputs the result as a JSON object instead of a tree. .PARAMETER OutputFile If specified, writes the output (tree or JSON) to this file. .EXAMPLE Get-DirectoryTree -RootPath "." -MaxDepth 2 -Filter Folders # Shows a 2-level folder structure from current directory .EXAMPLE Get-DirectoryTree -RootPath "./src" -IgnoreGitIgnore -AsJson # Outputs structure of ./src as JSON, skipping .gitignore processing .EXAMPLE Get-DirectoryTree -RootPath "." -AsJson -OutputFile "structure.json" # Writes full directory structure to structure.json in JSON format .EXAMPLE Get-DirectoryTree -RootPath "." -OutputFile "tree.txt" # Writes plain text tree structure to tree.txt #> param ( [Parameter(Mandatory = $true)] [string]$RootPath, [int]$MaxDepth = 100, [ValidateSet("All", "Folders", "Files")] [string]$Filter = "All", [switch]$IgnoreGitIgnore, [switch]$AsJson, [string]$OutputFile ) if (-not [System.IO.Path]::IsPathRooted($RootPath)) { $ResolvedPath = Normalize-PathUnixStyle (Resolve-Path -Path $RootPath -ErrorAction Stop).path } else { $ResolvedPath = Normalize-PathUnixStyle $RootPath } $ResolvedPath = $ResolvedPath.Trim().TrimEnd("/", "\") if (!(Test-Path -LiteralPath $ResolvedPath)) { throw "Path does not exist: $ResolvedPath" } if ($OutputFile) { $outputDir = Split-Path -Path $OutputFile -Parent if (-not $outputDir) { $outputDir = Get-Location } if (-not (Test-Path -LiteralPath $outputDir)) { throw "The directory for the output file does not exist: $outputDir" } if (Test-Path -LiteralPath $OutputFile -PathType Container) { throw "The specified output file path is a directory: $OutputFile" } } $script:progressDirectories = 0 $script:progressFiles = 0 $script:UnixPathMap = @{} $script:RegexMap = @{} if ($IgnoreGitIgnore) { Write-Host "Skipping .gitignore, traversing all folders" -ForegroundColor DarkYellow } $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() if ($AsJson) { $tree = Get-Structure -Path $ResolvedPath -MaxDepth $MaxDepth -Filter $Filter -IgnoreGitIgnore:$IgnoreGitIgnore -AsObject $json = $tree | ConvertTo-Json -Depth 100 if ($OutputFile) { $json | Set-Content -Path $OutputFile -Encoding UTF8 Write-Host "JSON output written to: $OutputFile" -ForegroundColor Green } else { $json } } elseif ($OutputFile) { $tree = Get-Structure -Path $ResolvedPath -MaxDepth $MaxDepth -Filter $Filter -IgnoreGitIgnore:$IgnoreGitIgnore (Get-TreeOutput $tree -AsList) | Set-Content -Path $OutputFile -Encoding UTF8 Write-Host "Tree output written to: $OutputFile" -ForegroundColor Green } else { $tree = Get-Structure -Path $ResolvedPath -MaxDepth $MaxDepth -Filter $Filter -IgnoreGitIgnore:$IgnoreGitIgnore Get-TreeOutput $tree } Write-Verbose ("Scanned $script:progressDirectories directories and $script:progressFiles files in {0:N2} seconds" -f $stopwatch.Elapsed.TotalSeconds) } |