Skip to main content

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

Following on from my GNOME and macOS 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.

View as: JSON Markdown

If you enjoyed this post or found it useful, you can subscribe to my RSS feed.

Similar posts

  1. PHOSPHOR: an entropy-based password generator

    I teamed up with Gemini and Claude to build a retro-futuristic password generator. It's a PWA that harvests hardware entropy from your movements, runs them through a SHA-256 math blender, and looks great doing it.

    pwa tools ai passwords
  2. How to Launch VS Code Projects via macOS Spotlight

    I recently traded my Linux setup for a MacBook Pro, but I wasn't willing to give up my custom project launchers. With a little help from Claude, I've ported my original Bash script to macOS so I can open any project instantly using Cmd + Space.

    macos apple vscode spotlight
  3. GNOME menu entries for Visual Studio Code projects

    I work on a large number of code projects and I wanted a quick way to open any of my projects in Visual Studio Code, my preferred code editor. I figured the quickest way to do this under GNOME would be to create a .desktop file for each project directory.

    gnome vscode linux
  4. My concerns with generative AI

    A post where I try to clarify my current stance on LLM/AI. I share my concerns about data privacy, energy usage and Big Tech influence.

    ai microsoft mozilla privacy trends environment
  5. AI in Firefox

    Some thoughts about Mozilla's decision to build AI features into the Firefox web browser.

    firefox ai mozilla opinions