Public/Get-Tree.ps1

# (c) 2026 Luqmaan Abdallah | MIT License

function Get-Tree {
<#
.SYNOPSIS
    Generates a directory tree structure and copies it to the clipboard.

.DESCRIPTION
    Get-Tree scans a directory and creates a visual representation (Classic, Modern, or Visual).
    The output is automatically copied to the clipboard for easy pasting into documentation or chat.

.PARAMETER Path
    The folder path to scan. Defaults to the current directory (.).
    If a style name (e.g., 'modern') is passed here and the path doesn't exist, it switches the style instead.

.PARAMETER Style
    The visual style of the tree:
    - Classic: Uses + and - (Default)
    - Modern: Uses box-drawing characters (├─, └─)
    - Visual: Uses Folder and File emojis

.PARAMETER Quiet
    If specified, suppresses all console output except for the tree itself if piped.

.PARAMETER Depth
    Limits how many levels deep the scan goes. 0 is infinite.

.EXAMPLE
    Get-Tree
    Generates a classic style tree of the current directory and copies it to the clipboard.

.EXAMPLE
    Get-Tree -Path .\src -Style Modern
    Generates a tree of the 'src' folder using box-drawing characters (├─, └─).

.EXAMPLE
    ct -Style Visual
    Uses the short alias 'ct' to generate a tree using Folder and File emojis.

.EXAMPLE
    Get-Tree -Depth 2
    Scans the current directory but limits the output to only 2 levels of nesting.

.EXAMPLE
    Get-Tree -DirectoryOnly
    Generates a tree consisting only of folders, ignoring all files.

.EXAMPLE
    Get-Tree "Modern"
    Uses the shorthand logic to switch the style to 'Modern' for the current directory scan.

.EXAMPLE
    Get-Tree -Quiet
    Suppresses the "Copied to clipboard" and "Items found" status messages, outputting only the raw tree.

.EXAMPLE
    "C:\Temp", "C:\Logs" | Get-Tree
    Processes multiple paths from the pipeline, generating and copying trees for each.

.EXAMPLE
    Get-Tree > tree.txt
    Generates the directory tree, copies the result to the system clipboard, and redirects the console output to create 'tree.txt'.

.NOTES
    Requires Set-Clipboard. If the clipboard is unavailable, the tree is still output to the console.
#>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")]
    param(
        # The target directory.
        # Position 0: Allows 'Get-Tree C:\Windows' without typing -Path.
        # ValueFromPipeline: Allows '"C:\Path1", "C:\Path2" | Get-Tree'.
        [Parameter(Position = 0, Mandatory = $false, ValueFromPipeline = $true)]
        [string]$Path = ".",

        # The visual formatting style.
        # Position 1: Allows 'Get-Tree . Modern'.
        # Alias 's': Allows 'Get-Tree -s Modern'.
        # ArgumentCompleter: Provides Tab-Completion for Classic, Modern, and Visual in the terminal.
        # Default: Pulls from the $Script: variable set by Update-TreeConfig.
        [Parameter(Position = 1, Mandatory = $false)]
        [Alias('s')]
        [ArgumentCompleter({
            param($CommandName, $ParameterName, $WordToComplete, $CommandAst, $FakeBoundParameters)
            $null = $CommandName, $ParameterName, $CommandAst, $FakeBoundParameters

            ('Classic', 'Modern', 'Visual') | Where-Object { $_ -like "$WordToComplete*" }
        })]
        [string]$Style = $(if ($Script:GetTreeDefaultStyle) { $Script:GetTreeDefaultStyle } else { "Modern" }),

        # Suppresses status messages (e.g., "Copied to clipboard").
        # [switch]: A boolean toggle. Used as '-Quiet' (True) or omitted (False).
        # Default: Pulls from the session-wide default set by Update-TreeConfig.
        [Parameter(Mandatory = $false)]
        [Alias('q')]
        [switch]$Quiet = $Script:GetTreeDefaultQuiet,

        # Controls recursion depth.
        # Position 2: Allows 'Get-Tree . Modern 2'.
        # [int]: Defaults to 0 (infinite recursion) unless specified.
        [Parameter(Mandatory = $false, Position = 2)]
        [Alias('d')]
        [int]$Depth = 0,

        # Filter to show only folders in the output.
        # [switch]: If present, Get-ChildItem will be told to ignore files.
        [Parameter(Mandatory = $false)]
        [Alias('do')]
        [switch]$DirectoryOnly
    )

    process {
        # Logic: If the 'Path' provided doesn't actually exist on the disk,
        # AND it matches one of our style keywords, we assume the user meant:
        # 'Get-Tree -Style <keyword>' instead of 'Get-Tree -Path <keyword>'.

        # Defines a list of keywords that we recognize as 'Styles' instead of 'Paths'.
        $StyleKeywords = @('classic', 'modern', 'visual', 'c', 'm', 'v')
        if (-not (Test-Path -Path $Path) -and ($StyleKeywords -contains $Path.ToLower())) {
            $Style = $Path # Shift the input over to the Style variable
            $Path = "." # Default the path back to the current directory
        }

        try {
            # Resolve-Path converts relative paths (like '..') or paths with wildcards
            # into absolute filesystem paths. -ErrorAction Stop triggers the catch block if it fails.
            $ResolvedPath = Resolve-Path -Path $Path -ErrorAction Stop

            # We use .Path to ensure we have the string literal path,
            # avoiding issues with provider-prefixed paths (like 'Microsoft.PowerShell.Core\FileSystem::C:\...')
            $RootPath = $ResolvedPath.Path
        }
        catch {
            # Graceful exit if the directory doesn't exist and isn't a recognized style keyword.
            Write-Error "Could not find path: $Path"
            return
        }

        # .treeignore Logic

        # 1. Define the location of the .treeignore file relative to the root being scanned.
        $IgnoreFile = Join-Path $RootPath ".treeignore"

        # 2. Initialize the list with "Hardcoded" ignores.
        # You always want to hide the tool's own config and the heavy .git folder.
        $IgnoreList = @(".treeignore", ".git")

        # 3. Check if a local .treeignore file exists.
        if (Test-Path $IgnoreFile) {
            # 4. Read the file and clean up the entries.
            $IgnoreList += Get-Content $IgnoreFile |
                # Remove empty lines or lines with only whitespace
                Where-Object { $_ -match '\S' } |
                # Trim spaces and strip trailing slashes so matching is consistent
                ForEach-Object { $_.Trim().TrimEnd('\').TrimEnd('/') }
        }

        # Define the core parameters for the file system scan
        function Invoke-TreeRecursive {
            param(
                [string]$CurrentPath,
                [int]$CurrentDepth,
                [string]$Prefix,
                [int]$MaxDepth,
                [bool]$OnlyDirs
            )

            if ($MaxDepth -gt 0 -and $CurrentDepth -ge $MaxDepth) { return }

            $gciParams = @{ Path = $CurrentPath; Force = $true }
            if ($OnlyDirs) { $gciParams['Directory'] = $true }

            $Items = Get-ChildItem @gciParams | Where-Object {
                $item = $_
                $shouldIgnore = $false
                foreach ($pattern in $IgnoreList) {
                    if ($item.Name -like $pattern) { $shouldIgnore = $true; break }
                }
                !$shouldIgnore
            } | Sort-Object PSIsContainer, Name -Descending

            $Count = $Items.Count
            for ($i = 0; $i -lt $Count; $i++) {
                $item = $Items[$i]
                $isLast = ($i -eq $Count - 1)

                $c_mid = "$([char]0x251C)$([char]0x2500) " # ├─
                $c_end = "$([char]0x2514)$([char]0x2500) " # └─

                $symbol = switch -Wildcard ($Style) {
                    "m*" { if ($isLast) { $c_end } else { $c_mid } }
                    "v*" { if ($item.PSIsContainer) { "$([char]0xD83D)$([char]0xDCC1) " } else { "$([char]0xD83D)$([char]0xDCC4) " } }
                    Default { if ($item.PSIsContainer) { "+ " } else { "- " } }
                }

                [void]$script:report.AppendLine("$Prefix$symbol$($item.Name)")

                if ($item.PSIsContainer) {
                    $indentChar = if ($Style -like "m*") { if ($isLast) { " " } else { "$([char]0x2502) " } }
                                  else { " " }

                    $newPrefix = $Prefix + $indentChar
                    Invoke-TreeRecursive -CurrentPath $item.FullName -CurrentDepth ($CurrentDepth + 1) -Prefix $newPrefix -MaxDepth $MaxDepth -OnlyDirs $OnlyDirs
                }
            }
        }

        # Build the String
        $script:report = [System.Text.StringBuilder]::new()

        # Pass the parameters into the recursive function
        Invoke-TreeRecursive -CurrentPath $RootPath -CurrentDepth 0 -Prefix "" -MaxDepth $Depth -OnlyDirs $DirectoryOnly.IsPresent

        $finalTree = $script:report.ToString()
        $E = [char]27

        if ([string]::IsNullOrWhiteSpace($finalTree)) {
            if (-not $Quiet) { Write-Host "$E[33mNo items found in: $RootPath$E[0m" }
            return
        }

        if ($MyInvocation.ExpectingInput -or $PSCmdlet.MyInvocation.PipelineLength -gt 1 -or -not $Quiet) {
            Write-Output $finalTree
        }

        try {
            $finalTree | Set-Clipboard -ErrorAction Stop
            if (-not $Quiet) {
                Write-Host "${E}[32m${E}[3mCopied to clipboard${E}[0m"
            }
        }
        catch {
            if (-not $Quiet) { Write-Host "$E[33m$E[3mClipboard unavailable.$E[0m" }
        }
    }
}