# How to Launch WSL VS Code Projects from the Windows Start Menu

Following on from my [GNOME](https://blog.philipnewborough.co.uk/posts/gnome-menu-entries-for-visual-studio-code-projects) and [macOS](https://blog.philipnewborough.co.uk/posts/how-to-launch-vs-code-projects-via-macos-spotlight) scripts, this Windows PowerShell version does the same job for Windows. It detects your default WSL distro, scans your projects directory inside WSL for `.projectname` files, and creates Start Menu shortcuts, with optional desktop shortcuts, that open each named WSL project directly in Windows VS Code via the WSL remote URI.

I do not use Windows very much, but it is nice to know that when I do, I can keep the same easy workflow for opening projects. And, having seen a few reviews of the latest Qualcomm Snapdragon-powered laptops, it does not seem impossible that I might end up using a Windows laptop at some point.

Anyway, here is the script. Be sure to change the `$ProjectsDirInWsl` value before using it. **Full disclosure:**  I vibecoded most of this with Claude, because unlike Linux shell scripting, PowerShell is not something I have much experience with.

```
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

# ── Configuration ──────────────────────────────────────────────────────────────
$ProjectsDirInWsl           = "/home/username/Projects"
$LauncherFolderName         = "VS Code WSL Projects"
$AlsoCreateDesktopShortcuts = $false

# ── Helpers ────────────────────────────────────────────────────────────────────
function Get-DefaultWslDistro {
    # wsl --list outputs UTF-16 LE; temporarily switch console encoding so
    # PowerShell decodes the bytes correctly.
    $prev = [Console]::OutputEncoding
    [Console]::OutputEncoding = [System.Text.Encoding]::Unicode
    try {
        $lines = & wsl.exe --list --verbose 2>&1
    } finally {
        [Console]::OutputEncoding = $prev
    }

    if (-not $lines) {
        throw "No output from 'wsl --list --verbose'. Is WSL installed and enabled?"
    }

    foreach ($line in $lines) {
        # Strip residual null bytes that can survive the UTF-16 decode, then trim.
        $clean = ($line -replace '\x00', '').Trim()

        # The default distro line begins with an asterisk.
        if ($clean -notmatch '^\*') { continue }

        # Remove the "* " marker, then split on the multi-space column separator
        # to isolate the NAME column. This correctly handles distro names that
        # contain spaces (e.g. "Ubuntu 22.04").
        $name = ($clean -replace '^\*\s+', '') -split '\s{2,}' | Select-Object -First 1
        $name = $name.Trim()
        if ($name) { return $name }
    }

    throw "No default WSL distro found. Set one with: wsl --set-default <DistroName>"
}

function Invoke-WslScript {
    # Writes the bash script to a temp file inside WSL and executes it there,
    # avoiding stdin-truncation and CRLF issues that affect PowerShell pipes.
    param(
        [Parameter(Mandatory)][string]$Distro,
        [Parameter(Mandatory)][string]$Script
    )

    # Normalise to LF so bash doesn't see stray CR characters.
    $lfScript = $Script -replace "`r`n", "`n" -replace "`r", "`n"

    # Write to a Windows temp file with UTF-8 no-BOM encoding.
    $tmpWin = [System.IO.Path]::GetTempFileName()
    $output = $null
    try {
        [System.IO.File]::WriteAllText($tmpWin, $lfScript, (New-Object System.Text.UTF8Encoding $false))

        # Convert the Windows path to a WSL path.
        $prev = [Console]::OutputEncoding
        [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
        try {
            $tmpWslRaw = & wsl.exe -d $Distro -- wslpath -u ($tmpWin -replace '\\', '/')
            if ($LASTEXITCODE -ne 0 -or -not $tmpWslRaw) {
                throw "wslpath failed (exit $LASTEXITCODE) for: $tmpWin"
            }
            $tmpWsl = "$tmpWslRaw".Trim()
            $output = & wsl.exe -d $Distro -- bash $tmpWsl
        } finally {
            [Console]::OutputEncoding = $prev
        }
    } finally {
        Remove-Item -LiteralPath $tmpWin -Force -ErrorAction SilentlyContinue
    }

    if ($LASTEXITCODE -ne 0) {
        throw "WSL bash exited with code $LASTEXITCODE."
    }

    return $output
}

function Get-VsCodeExe {
    $candidates = @(
        "$env:LOCALAPPDATA\Programs\Microsoft VS Code\Code.exe",
        "$env:ProgramFiles\Microsoft VS Code\Code.exe",
        "${env:ProgramFiles(x86)}\Microsoft VS Code\Code.exe"
    )

    foreach ($path in $candidates) {
        if ($path -and (Test-Path -LiteralPath $path)) { return $path }
    }

    $cmd = Get-Command code -ErrorAction SilentlyContinue
    if ($cmd) { return $cmd.Source }

    throw "VS Code not found. Please install VS Code for Windows first."
}

function Get-SafeWindowsFileName {
    param([Parameter(Mandatory)][string]$Name)
    $safe = ($Name -replace '[<>:"/\\|?*]', '_').Trim()
    if (-not $safe) { throw "Project name '$Name' resolves to an empty Windows filename." }
    return $safe
}

function New-VsCodeShortcut {
    param(
        [Parameter(Mandatory)][string]$LnkPath,
        [Parameter(Mandatory)][string]$CodeExe,
        [Parameter(Mandatory)][string]$FolderUri,
        [Parameter(Mandatory)][string]$Description
    )
    $wsh             = New-Object -ComObject WScript.Shell
    $sc              = $wsh.CreateShortcut($LnkPath)
    $sc.TargetPath       = $CodeExe
    $sc.Arguments        = "--folder-uri `"$FolderUri`""
    $sc.WorkingDirectory = $env:USERPROFILE
    $sc.Description      = $Description
    $sc.IconLocation     = "$CodeExe,0"
    $sc.Save()
}

# ── Discover environment ────────────────────────────────────────────────────────
if ($env:OS -ne 'Windows_NT') { throw "This script must be run on Windows." }

$Distro      = Get-DefaultWslDistro
$CodeExe     = Get-VsCodeExe
$LauncherDir = Join-Path ([Environment]::GetFolderPath('Programs')) $LauncherFolderName
$DesktopDir  = [Environment]::GetFolderPath('Desktop')

Write-Host "WSL distro    : $Distro"
Write-Host "Projects dir  : $ProjectsDirInWsl"
Write-Host "VS Code       : $CodeExe"
Write-Host "Launcher dir  : $LauncherDir"
Write-Host ""

if ([string]::IsNullOrWhiteSpace($LauncherDir) -or $LauncherDir.Length -lt 5) {
    throw "Launcher directory path looks wrong: '$LauncherDir'"
}

# ── Find all projects in a single WSL call ─────────────────────────────────────
# Output format per project: <absolute-wsl-dir> TAB <project-name>
# Using .Replace() (not -replace) to avoid regex interpretation of the path.
$discoveryScript = @'
PROJECTS_DIR="__PROJECTS_DIR__"

if [ ! -d "$PROJECTS_DIR" ]; then
    echo "Directory not found in WSL: $PROJECTS_DIR" >&2
    exit 1
fi

find "$PROJECTS_DIR" -type f -name '.projectname' 2>/dev/null | sort |
while IFS= read -r f; do
    dir="${f%/*}"
    name=$(head -n1 "$f" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
    [ -n "$name" ] && printf '%s\t%s\n' "$dir" "$name"
done
'@

$discoveryScript = $discoveryScript.Replace('__PROJECTS_DIR__', $ProjectsDirInWsl)
$projectLines    = @(Invoke-WslScript -Distro $Distro -Script $discoveryScript |
                     Where-Object { $_ -match "`t" })

Write-Host "Projects found: $($projectLines.Count)"
Write-Host ""

# ── Prepare the Start Menu folder ──────────────────────────────────────────────
if (Test-Path -LiteralPath $LauncherDir) {
    Write-Host "Clearing existing launchers..."
    Get-ChildItem -LiteralPath $LauncherDir -Filter '*.lnk' | Remove-Item -Force
} else {
    $null = New-Item -ItemType Directory -Path $LauncherDir
}

# ── Create shortcuts ───────────────────────────────────────────────────────────
$count = 0

foreach ($line in $projectLines) {
    $tab = $line.IndexOf("`t")
    if ($tab -lt 1) { continue }

    $projectDir  = $line.Substring(0, $tab).Trim()
    $projectName = $line.Substring($tab + 1).Trim()
    if (-not $projectDir -or -not $projectName) { continue }

    $safeName  = Get-SafeWindowsFileName $projectName
    $folderUri = "vscode-remote://wsl+$Distro$projectDir"
    $desc      = "Open $projectName in VS Code (WSL: $Distro)"
    $lnkPath   = Join-Path $LauncherDir "$safeName.lnk"

    New-VsCodeShortcut -LnkPath $lnkPath -CodeExe $CodeExe -FolderUri $folderUri -Description $desc
    Write-Host "  + $safeName.lnk"
    $count++

    if ($AlsoCreateDesktopShortcuts) {
        New-VsCodeShortcut `
            -LnkPath      (Join-Path $DesktopDir "$safeName.lnk") `
            -CodeExe      $CodeExe `
            -FolderUri    $folderUri `
            -Description  $desc
    }
}

Write-Host ""
Write-Host "$count launcher(s) written to: $LauncherDir"

if ($AlsoCreateDesktopShortcuts -and $count -gt 0) {
    Write-Host "Desktop shortcuts also written to: $DesktopDir"
}
```

### Bonus: Turn the script into an `.exe`

If you prefer, you can wrap the script as a standalone executable and drop it somewhere convenient.

```
Install-Module PS2EXE -Scope CurrentUser
Invoke-PS2EXE .\Generate-VSCodeWSLLaunchers.ps1 .\Generate-VSCodeWSLLaunchers.exe
```

After that, you can just run the `.exe` directly.