From 2b2d7ad0961a1fbdf831fad15de4547cb3f70b7c Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 12:31:04 +0200 Subject: [PATCH 01/24] feat(sessions): first-class WSL session type alongside Local and SSH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the binary IsRemote flag with a three-valued SessionKind enum so a WSL session can live alongside Local and SSH ones — picking a distro from a combobox (auto-detected via `wsl -l -v`), pointing at a Linux working folder, and optionally pinning a -u user. wsl.exe is the launch process; PTY plumbing is unchanged. Legacy state.json that only carried IsRemote still deserializes cleanly: the IsRemote setter promotes Kind from Local to Ssh, so older files migrate on first load without bespoke conversion code. WSL sessions store their WorkingFolder as a `\\wsl$\\...` UNC view of the Linux path so Git for Windows, the "Open in Explorer" menu, and the sidebar's git-branch poll all keep working unmodified — git status, dirty state, and repo-root-based accent colors light up the same as for Local sessions. Run-commands (the F5 / chips strip) now also dispatch correctly inside WSL by wrapping the command in `wsl.exe -d ... -- bash -lc ''`. The three IsRemote display-label / accent-key ternaries that were drifting across MainWindow + SessionViewModel are consolidated onto three small helpers on ShellSession (DefaultDisplayName / FolderShort / AccentKey) so adding a fourth session kind in future doesn't require chasing call sites. --- README.md | 1 + src/CodeShellManager/MainWindow.xaml.cs | 64 +++++---- src/CodeShellManager/Models/ShellSession.cs | 126 ++++++++++++++++- src/CodeShellManager/Services/RunInstance.cs | 71 +++++++--- .../Services/WslDiscoveryService.cs | 131 ++++++++++++++++++ .../ViewModels/SessionViewModel.cs | 32 ++--- .../Views/NewSessionDialog.xaml | 33 +++++ .../Views/NewSessionDialog.xaml.cs | 92 +++++++++++- .../RunInstanceTests.cs | 28 ++++ .../ShellSessionMigrationTests.cs | 93 +++++++++++++ .../ShellSessionTests.cs | 100 +++++++++++++ .../WslDiscoveryServiceTests.cs | 85 ++++++++++++ 12 files changed, 776 insertions(+), 80 deletions(-) create mode 100644 src/CodeShellManager/Services/WslDiscoveryService.cs create mode 100644 tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs create mode 100644 tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs diff --git a/README.md b/README.md index 1cef29b..383c071 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Built with WPF + [xterm.js](https://xtermjs.org/) + Windows ConPTY for full pseu - **SSH remote sessions** — connect to remote hosts using your existing SSH config; sessions persist across restarts - **Windows Terminal profile import** — opt-in import of profiles from Windows Terminal's `settings.json`; pick a profile in the New Session dialog to stamp its font, color scheme, cursor and padding onto the new terminal - **Launch & shutdown spinners** — every starting session shows a brief overlay (`Starting …` or `Connecting to …`) until the first PTY byte arrives; closing the window shows a "Shutting down…" overlay during session disposal +- **WSL sessions** — first-class session type for any installed WSL distro: distro picker (auto-detected via `wsl -l -v`), Linux working folder, optional `-u` user override; git status works via the `\\wsl$\` UNC view - **Session history** — clicking a search result from a closed session offers to relaunch it - **Configurable launch commands** — customise the commands available in the New Session dialog - **Claude badge** — sessions running `claude` commands get a visual indicator diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 61bf63e..38e0658 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -455,12 +455,24 @@ private void OpenNewSessionDialogCore(string defaultFolder, SessionViewModel? pa if (dialog.IsRemote) { - session.IsRemote = true; + session.Kind = Models.SessionKind.Ssh; session.SshUser = dialog.SshUser; session.SshHost = dialog.SshHost; session.SshPort = dialog.SshPort; session.SshRemoteFolder = dialog.SshRemoteFolder; } + else if (dialog.IsWsl) + { + session.Kind = Models.SessionKind.Wsl; + session.WslDistro = dialog.WslDistro; + session.WslUser = dialog.WslUser; + session.WslWorkingFolder = dialog.WslWorkingFolder; + // The session's WorkingFolder stays as a Windows UNC view of the same path + // so anything that touches the filesystem (git status, "open in Explorer") + // resolves correctly. Empty = unmounted; LaunchSessionAsync falls back. + session.WorkingFolder = Services.WslDiscoveryService.ToUncPath( + dialog.WslDistro, dialog.WslWorkingFolder); + } // Profile overrides come from the dialog (which may have copied from a Windows Terminal // profile). When the dialog left them blank and we have a parent, inherit the parent's. @@ -603,14 +615,20 @@ private async Task DuplicateSessionAsync(SessionViewModel parent) string.IsNullOrEmpty(p.GroupId) ? null : p.GroupId, colorOverride: null, afterSessionId: parent.Id); - if (p.IsRemote) + clone.Kind = p.Kind; + if (p.Kind == Models.SessionKind.Ssh) { - clone.IsRemote = true; clone.SshUser = p.SshUser; clone.SshHost = p.SshHost; clone.SshPort = p.SshPort; clone.SshRemoteFolder = p.SshRemoteFolder; } + else if (p.Kind == Models.SessionKind.Wsl) + { + clone.WslDistro = p.WslDistro; + clone.WslUser = p.WslUser; + clone.WslWorkingFolder = p.WslWorkingFolder; + } clone.ProfileFontFamily = p.ProfileFontFamily; clone.ProfileFontSize = p.ProfileFontSize; clone.ProfileFontWeight = p.ProfileFontWeight; @@ -698,7 +716,9 @@ private async Task LaunchSessionInSiblingWorktreeAsync(SessionViewModel parent, /// private void SeedRunCommandsAsync(Models.ShellSession session) { - if (session.IsRemote) return; + // Templates are local-only — SSH and WSL working folders are out of reach for + // the synchronous Directory.EnumerateFiles probe in RunCommandTemplatesService. + if (session.Kind != Models.SessionKind.Local) return; if (session.RunCommands.Count > 0) return; if (string.IsNullOrWhiteSpace(session.WorkingFolder)) return; @@ -1038,12 +1058,21 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal string effectiveArgs; string workDir; - if (session.IsRemote) + if (session.Kind == Models.SessionKind.Ssh) { effectiveCommand = "ssh"; effectiveArgs = session.BuildSshArgs(); workDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); } + else if (session.Kind == Models.SessionKind.Wsl) + { + // wsl.exe handles its own cwd via --cd inside BuildWslArgs; pass the user + // profile as the launching process's cwd so CreateProcess never sees a UNC + // path it might reject. + effectiveCommand = "wsl.exe"; + effectiveArgs = session.BuildWslArgs(); + workDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } else { workDir = Directory.Exists(session.WorkingFolder) @@ -4157,9 +4186,7 @@ private Border BuildLaunchingSidebarItem(ShellSession session) var textPanel = new StackPanel { Margin = new Thickness(8, 6, 4, 6) }; string displayName = string.IsNullOrWhiteSpace(session.Name) - ? (session.IsRemote - ? (string.IsNullOrWhiteSpace(session.SshHost) ? session.Command : session.SshHost) - : System.IO.Path.GetFileName(session.WorkingFolder.TrimEnd('/', '\\')) ?? session.Command) + ? session.DefaultDisplayName : session.Name; var nameText = new TextBlock @@ -4171,11 +4198,7 @@ private Border BuildLaunchingSidebarItem(ShellSession session) TextTrimming = TextTrimming.CharacterEllipsis }; - string folderShort = session.IsRemote - ? (string.IsNullOrWhiteSpace(session.SshHost) ? "" : session.SshHost) - : (string.IsNullOrEmpty(session.WorkingFolder) - ? "" - : new System.IO.DirectoryInfo(session.WorkingFolder).Name); + string folderShort = session.FolderShort; var folderText = new TextBlock { @@ -4252,9 +4275,7 @@ private Border BuildDormantSidebarItem(ShellSession session) var textPanel = new StackPanel { Margin = new Thickness(8, 6, 4, 6) }; string displayName = string.IsNullOrWhiteSpace(session.Name) - ? (session.IsRemote - ? (string.IsNullOrWhiteSpace(session.SshHost) ? session.Command : session.SshHost) - : System.IO.Path.GetFileName(session.WorkingFolder.TrimEnd('/', '\\')) ?? session.Command) + ? session.DefaultDisplayName : session.Name; var nameText = new TextBlock @@ -4266,11 +4287,7 @@ private Border BuildDormantSidebarItem(ShellSession session) TextTrimming = TextTrimming.CharacterEllipsis }; - string folderShort = session.IsRemote - ? (string.IsNullOrWhiteSpace(session.SshHost) ? "" : session.SshHost) - : (string.IsNullOrEmpty(session.WorkingFolder) - ? "" - : new System.IO.DirectoryInfo(session.WorkingFolder).Name); + string folderShort = session.FolderShort; var folderText = new TextBlock { @@ -4358,10 +4375,7 @@ private static bool IsDescendantOf(System.Windows.DependencyObject node, System. } private static string GetAccentForSession(ShellSession s) => - s.ColorOverride ?? ColorService.GetHexColor( - s.IsRemote - ? (string.IsNullOrWhiteSpace(s.SshUser) ? s.SshHost : $"{s.SshUser}@{s.SshHost}") - : s.WorkingFolder); + s.ColorOverride ?? ColorService.GetHexColor(s.AccentKey); // ── Search ──────────────────────────────────────────────────────────────── diff --git a/src/CodeShellManager/Models/ShellSession.cs b/src/CodeShellManager/Models/ShellSession.cs index de82a4b..6855d1f 100644 --- a/src/CodeShellManager/Models/ShellSession.cs +++ b/src/CodeShellManager/Models/ShellSession.cs @@ -6,6 +6,13 @@ namespace CodeShellManager.Models; public enum SessionStatus { Idle, Running, NeedsAttention, Exited } +/// +/// Kind of pseudo-terminal session. runs a Windows process directly, +/// tunnels through the system ssh client, launches +/// a shell inside a WSL distro via wsl.exe. +/// +public enum SessionKind { Local, Ssh, Wsl } + public class ShellSession { public string Id { get; set; } = Guid.NewGuid().ToString(); @@ -34,13 +41,41 @@ public class ShellSession /// public bool IsDormant { get; set; } + /// + /// Authoritative session kind. New code reads this; is kept + /// as a back-compat shim so legacy state.json (which only carried the SSH boolean) + /// continues to deserialize: on load, IsRemote=true promotes Kind to + /// . + /// + public SessionKind Kind { get; set; } = SessionKind.Local; + // SSH / remote session fields - public bool IsRemote { get; set; } + /// + /// Legacy SSH flag — true iff is . + /// Kept as a property (not just a computed getter) so old state.json files with + /// "IsRemote": true and no Kind key still migrate cleanly on + /// deserialization. The setter only promotes Local → Ssh; it never clears + /// Kind, so a JSON document with both IsRemote and Kind + /// (deserialized in any order) lands on the correct value. + /// + public bool IsRemote + { + get => Kind == SessionKind.Ssh; + set { if (value && Kind == SessionKind.Local) Kind = SessionKind.Ssh; } + } public string SshUser { get; set; } = ""; public string SshHost { get; set; } = ""; public int SshPort { get; set; } = 22; public string SshRemoteFolder { get; set; } = ""; + // WSL session fields + /// Name of the WSL distro (matches wsl -l -q), e.g. "Ubuntu". + public string WslDistro { get; set; } = ""; + /// Optional WSL user override (wsl -u <user>). Empty = the distro's default user. + public string WslUser { get; set; } = ""; + /// Linux-style working folder inside the distro, e.g. "/home/alice/project". Empty = the user's home. + public string WslWorkingFolder { get; set; } = ""; + // Per-session appearance overrides (typically populated from a Windows // Terminal profile via NewSessionDialog). All nullable — null means "use the // global terminal settings". Persisted to state.json so a session relaunches @@ -66,11 +101,12 @@ public class ShellSession public List RunCommands { get; set; } = new(); // Full command line for display and passthrough. - // For remote sessions: "ssh " - // For local sessions: "Command [Args]" - public string FullCommandLine => IsRemote - ? $"ssh {BuildSshArgs()}" - : (string.IsNullOrWhiteSpace(Args) ? Command : $"{Command} {Args}"); + public string FullCommandLine => Kind switch + { + SessionKind.Ssh => $"ssh {BuildSshArgs()}", + SessionKind.Wsl => $"wsl.exe {BuildWslArgs()}", + _ => string.IsNullOrWhiteSpace(Args) ? Command : $"{Command} {Args}", + }; /// /// Builds the argument string passed to the ssh executable. @@ -96,4 +132,82 @@ internal string BuildSshArgs() sb.Append("\""); return sb.ToString(); } + + /// + /// Builds the argument string passed to wsl.exe. + /// Example: "-d Ubuntu -u alice --cd /home/alice/project -- bash -lc \"claude\"" + /// The command is wrapped in bash -lc so PATH-resolved tools (nvm-managed + /// node, pyenv, etc.) work the same as in a user-launched login shell. + /// + internal string BuildWslArgs() + { + if (string.IsNullOrWhiteSpace(WslDistro)) + throw new InvalidOperationException("WslDistro must be set for WSL sessions."); + var sb = new StringBuilder(); + sb.Append($"-d {WslDistro}"); + if (!string.IsNullOrWhiteSpace(WslUser)) + sb.Append($" -u {WslUser}"); + if (!string.IsNullOrWhiteSpace(WslWorkingFolder)) + sb.Append($" --cd {WslWorkingFolder}"); + var shell = string.IsNullOrWhiteSpace(Command) ? "bash" : Command; + string inner = string.IsNullOrWhiteSpace(Args) ? shell : $"{shell} {Args}"; + sb.Append($" -- bash -lc \"{inner.Replace("\"", "\\\"")}\""); + return sb.ToString(); + } + + // ── Display helpers (single source of truth — see MainWindow sidebar / VM) ──── + + /// + /// Subtitle-line text for the sidebar: a short, kind-appropriate locator. + /// Local → working folder leaf; Ssh → host; Wsl → distro:linux-leaf. + /// + public string FolderShort => Kind switch + { + SessionKind.Ssh => string.IsNullOrWhiteSpace(SshHost) ? "" : SshHost, + SessionKind.Wsl => BuildWslFolderShort(), + _ => string.IsNullOrEmpty(WorkingFolder) + ? "" + : new System.IO.DirectoryInfo(WorkingFolder).Name, + }; + + /// + /// What to show as the session's label when is blank. + /// + public string DefaultDisplayName => Kind switch + { + SessionKind.Ssh => string.IsNullOrWhiteSpace(SshHost) ? Command : SshHost, + SessionKind.Wsl => string.IsNullOrWhiteSpace(WslDistro) + ? Command + : (string.IsNullOrEmpty(WslWorkingFolder) + ? WslDistro + : $"{WslDistro}: {LeafName(WslWorkingFolder)}"), + _ => System.IO.Path.GetFileName(WorkingFolder.TrimEnd('/', '\\')) ?? Command, + }; + + /// + /// Key used by ColorService to pick a deterministic accent color. Worktree + /// siblings share an accent via the repo-root override done in ; + /// this is the base key when no repo-root is known. + /// + public string AccentKey => Kind switch + { + SessionKind.Ssh => string.IsNullOrWhiteSpace(SshUser) ? SshHost : $"{SshUser}@{SshHost}", + SessionKind.Wsl => $"wsl://{WslDistro}{WslWorkingFolder}", + _ => WorkingFolder, + }; + + private string BuildWslFolderShort() + { + if (string.IsNullOrWhiteSpace(WslDistro)) return ""; + string leaf = LeafName(WslWorkingFolder); + return string.IsNullOrEmpty(leaf) ? WslDistro : $"{WslDistro}: {leaf}"; + } + + private static string LeafName(string linuxPath) + { + if (string.IsNullOrWhiteSpace(linuxPath)) return ""; + string trimmed = linuxPath.TrimEnd('/'); + int slash = trimmed.LastIndexOf('/'); + return slash >= 0 ? trimmed[(slash + 1)..] : trimmed; + } } diff --git a/src/CodeShellManager/Services/RunInstance.cs b/src/CodeShellManager/Services/RunInstance.cs index f2dbe12..7c21e40 100644 --- a/src/CodeShellManager/Services/RunInstance.cs +++ b/src/CodeShellManager/Services/RunInstance.cs @@ -70,8 +70,9 @@ internal RunInstance(RunCommandItem item, Func ptyFactory) } /// - /// Spawns the child PTY. Builds the command line based on whether the parent - /// is local or remote — see / . + /// Spawns the child PTY. Builds the command line based on the parent's + /// — see , + /// , and . /// public void Start(ShellSession parent) { @@ -90,28 +91,36 @@ public void Start(ShellSession parent) _pty.Exited += OnPtyExited; string command, args, workDir; - if (parent.IsRemote) + switch (parent.Kind) { - // SSH parents always go through bash — Mode is meaningless for remote runs. - command = "ssh"; - args = BuildSshArgs(parent, CommandLine); - workDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - } - else if (Mode == RunMode.PowerShell) - { - command = ResolvePwsh(); - args = BuildPwshArgs(CommandLine); - workDir = Directory.Exists(parent.WorkingFolder) - ? parent.WorkingFolder - : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - } - else - { - command = "cmd"; - args = BuildLocalCmd(CommandLine); - workDir = Directory.Exists(parent.WorkingFolder) - ? parent.WorkingFolder - : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + case SessionKind.Ssh: + // SSH parents always go through bash — Mode is meaningless for remote runs. + command = "ssh"; + args = BuildSshArgs(parent, CommandLine); + workDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + break; + case SessionKind.Wsl: + // WSL parents wrap the command in `wsl.exe … -- bash -lc` — + // running pwsh inside WSL is out of scope so Mode is ignored here too. + command = "wsl.exe"; + args = BuildWslArgs(parent, CommandLine); + workDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + break; + default: + if (Mode == RunMode.PowerShell) + { + command = ResolvePwsh(); + args = BuildPwshArgs(CommandLine); + } + else + { + command = "cmd"; + args = BuildLocalCmd(CommandLine); + } + workDir = Directory.Exists(parent.WorkingFolder) + ? parent.WorkingFolder + : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + break; } _pty.Start(command, args, workDir, cols: 200, rows: 50, useJobObject: true); @@ -261,6 +270,22 @@ internal static string BuildSshArgs(ShellSession parent, string commandLine) return sb.ToString(); } + /// + /// Builds wsl.exe args for a run executed inside the parent's WSL distro. Pattern: + /// -d <distro> [-u <user>] [--cd <folder>] -- bash -lc '<escaped>' + /// + internal static string BuildWslArgs(ShellSession parent, string commandLine) + { + var sb = new StringBuilder(); + sb.Append($"-d {parent.WslDistro}"); + if (!string.IsNullOrWhiteSpace(parent.WslUser)) sb.Append($" -u {parent.WslUser}"); + if (!string.IsNullOrWhiteSpace(parent.WslWorkingFolder)) + sb.Append($" --cd {parent.WslWorkingFolder}"); + sb.Append(" -- bash -lc "); + sb.Append(SingleQuoteEscape(commandLine)); + return sb.ToString(); + } + /// /// POSIX single-quote escape: wraps in single quotes, replacing any inner /// single quote with '\'' so the shell still receives the literal char. diff --git a/src/CodeShellManager/Services/WslDiscoveryService.cs b/src/CodeShellManager/Services/WslDiscoveryService.cs new file mode 100644 index 0000000..0681b4b --- /dev/null +++ b/src/CodeShellManager/Services/WslDiscoveryService.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace CodeShellManager.Services; + +/// +/// One installed WSL distro as reported by wsl -l -v. +/// +/// Distro name (matches the -d argument to wsl.exe). +/// WSL version (1 or 2). 0 if the column failed to parse. +/// True for the distro flagged with * in the listing. +/// Reported lifecycle state, e.g. "Running", "Stopped". +public record WslDistro(string Name, int Version, bool IsDefault, string State); + +/// +/// Enumerates WSL distros installed on the current Windows host. Returns an empty list +/// when wsl.exe is missing or returns an error (e.g. no distros installed). +/// +public static class WslDiscoveryService +{ + /// + /// Returns the currently installed distros. The result is suitable for populating + /// a UI picker; the default distro (if any) is marked via . + /// Never throws — every failure mode collapses to an empty list. + /// + public static async Task> GetDistrosAsync() + { + if (!OperatingSystem.IsWindows()) return Array.Empty(); + + try + { + var psi = new ProcessStartInfo("wsl.exe") + { + // -l -v is the verbose listing. --quiet is intentionally NOT used so we + // get the header row and the asterisk marker for the default distro. + Arguments = "-l -v", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + // wsl.exe writes its listings as UTF-16 LE (the same as PowerShell's + // default). Without this override we'd read each character interleaved + // with NUL bytes and the parser would see gibberish. + StandardOutputEncoding = Encoding.Unicode, + StandardErrorEncoding = Encoding.Unicode, + }; + + using var process = Process.Start(psi); + if (process is null) return Array.Empty(); + + var outTask = process.StandardOutput.ReadToEndAsync(); + var bothTask = Task.WhenAll(outTask, process.StandardError.ReadToEndAsync()); + var completed = await Task.WhenAny(bothTask, Task.Delay(3000)); + if (completed != bothTask) + { + try { process.Kill(); } catch { } + return Array.Empty(); + } + try { await process.WaitForExitAsync(); } catch { } + if (process.ExitCode != 0) return Array.Empty(); + + return Parse(outTask.Result); + } + catch (Win32Exception) + { + // wsl.exe not on PATH — WSL feature isn't installed. + return Array.Empty(); + } + catch (FileNotFoundException) + { + return Array.Empty(); + } + } + + /// + /// Parses the body of wsl -l -v. Exposed for testing. + /// + internal static IReadOnlyList Parse(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return Array.Empty(); + + var results = new List(); + foreach (var line in raw.Replace("\r", "").Split('\n')) + { + if (string.IsNullOrWhiteSpace(line)) continue; + + // Header row: " NAME STATE VERSION". + // Detect by the presence of the literal "NAME" token and skip. + if (line.TrimStart().StartsWith("NAME", StringComparison.Ordinal)) continue; + + var tokens = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length < 2) continue; + + bool isDefault = tokens[0] == "*"; + int idx = isDefault ? 1 : 0; + if (tokens.Length - idx < 1) continue; + + string name = tokens[idx]; + string state = tokens.Length - idx >= 2 ? tokens[idx + 1] : ""; + int version = 0; + if (tokens.Length - idx >= 3) int.TryParse(tokens[idx + 2], out version); + + results.Add(new WslDistro(name, version, isDefault, state)); + } + // Stable ordering: default first, then alphabetical. + return results + .OrderByDescending(d => d.IsDefault) + .ThenBy(d => d.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// + /// Converts a WSL distro + Linux-style path to the Windows UNC view of that path + /// (\\wsl$\Ubuntu\home\alice). Used by GitService and PseudoTerminal's + /// working-directory argument so Windows-native tools can read the WSL filesystem. + /// Returns an empty string when either input is empty. + /// + public static string ToUncPath(string distro, string linuxPath) + { + if (string.IsNullOrWhiteSpace(distro)) return ""; + if (string.IsNullOrWhiteSpace(linuxPath)) return $@"\\wsl$\{distro}"; + string trimmed = linuxPath.TrimStart('/').Replace('/', '\\'); + return $@"\\wsl$\{distro}\{trimmed}"; + } +} diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index abd4c29..e5467f4 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -41,31 +41,20 @@ public partial class SessionViewModel : ObservableObject, IDisposable public string AccentColor => Session.ColorOverride ?? ColorService.GetHexColor( - Session.IsRemote - ? (string.IsNullOrWhiteSpace(Session.SshUser) - ? Session.SshHost - : $"{Session.SshUser}@{Session.SshHost}") - // Key on RepoRoot when known so worktree siblings share a color; - // fall back to WorkingFolder for non-git sessions. - : (string.IsNullOrEmpty(RepoRoot) ? Session.WorkingFolder : RepoRoot)); + // SSH never gets a RepoRoot override (no local filesystem); for Local + WSL + // prefer RepoRoot so worktree siblings share a color, falling back to + // the kind-specific accent key. + Session.Kind == SessionKind.Ssh + ? Session.AccentKey + : (string.IsNullOrEmpty(RepoRoot) ? Session.AccentKey : RepoRoot)); partial void OnRepoRootChanged(string? value) => OnPropertyChanged(nameof(AccentColor)); public string DisplayName => string.IsNullOrWhiteSpace(Session.Name) - ? (Session.IsRemote - ? (string.IsNullOrWhiteSpace(Session.SshHost) ? Session.Command : Session.SshHost) - : System.IO.Path.GetFileName(Session.WorkingFolder.TrimEnd('/', '\\')) ?? Session.Command) + ? Session.DefaultDisplayName : Session.Name; - public string FolderShort - { - get - { - if (string.IsNullOrEmpty(Session.WorkingFolder)) return ""; - var di = new System.IO.DirectoryInfo(Session.WorkingFolder); - return di.Name; - } - } + public string FolderShort => Session.FolderShort; public event Action? CloseRequested; @@ -81,7 +70,10 @@ public SessionViewModel(ShellSession session) public async Task RefreshGitInfoAsync() { - if (Session.IsRemote) return; + // SSH sessions have no local working folder to inspect. WSL sessions store + // their WorkingFolder as a `\\wsl$\\...` UNC path, which Git for + // Windows handles via `git -C` — so the Local code path applies unchanged. + if (Session.Kind == SessionKind.Ssh) return; var (branch, isDirty) = await GitService.GetGitInfoAsync(Session.WorkingFolder); GitBranch = branch; GitIsDirty = isDirty; diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml b/src/CodeShellManager/Views/NewSessionDialog.xaml index 1d5b6da..6817a83 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml @@ -180,6 +180,10 @@ AutomationProperties.AutomationId="NewSessionRemoteRadio" Content="Remote (SSH)" Checked="SessionType_Changed"/> + @@ -229,6 +233,35 @@ ToolTip="e.g. /home/alice/project — leave blank for home directory"/> + + + + + + + + + + + + + + + + diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index f575d47..4f9a69f 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -31,6 +31,12 @@ public partial class NewSessionDialog : Window public string SshUser { get; private set; } = ""; public string SshRemoteFolder { get; private set; } = ""; + // WSL session output + public bool IsWsl { get; private set; } = false; + public string WslDistro { get; private set; } = ""; + public string WslUser { get; private set; } = ""; + public string WslWorkingFolder { get; private set; } = ""; + // Profile-driven appearance overrides (null when no profile picked) public string? ProfileFontFamily { get; private set; } public int? ProfileFontSize { get; private set; } @@ -121,17 +127,43 @@ public NewSessionDialog( FolderBox.TextChanged += (_, _) => { AutoFillName(); ScheduleWorktreeProbe(); }; SshHostBox.TextChanged += (_, _) => AutoFillName(); + WslDistroCombo.SelectionChanged += (_, _) => AutoFillName(); + WslWorkingFolderBox.TextChanged += (_, _) => AutoFillName(); Loaded += async (_, _) => { - if (!IsRemoteMode && !string.IsNullOrWhiteSpace(FolderBox.Text)) + if (IsLocalMode && !string.IsNullOrWhiteSpace(FolderBox.Text)) await ProbeSiblingWorktreesAsync(FolderBox.Text.Trim()); + await PopulateWslDistrosAsync(); }; } + /// + /// Fills WslDistroCombo from . + /// On hosts without WSL installed we leave the combo empty and surface a one-line hint + /// so the WSL radio doesn't appear broken. + /// + private async System.Threading.Tasks.Task PopulateWslDistrosAsync() + { + var distros = await WslDiscoveryService.GetDistrosAsync(); + WslDistroCombo.Items.Clear(); + if (distros.Count == 0) + { + WslHelpText.Text = "No WSL distros found. Install WSL from the Microsoft Store, then re-open this dialog."; + return; + } + foreach (var d in distros) + { + string label = d.IsDefault ? $"{d.Name} (default, v{d.Version})" : $"{d.Name} (v{d.Version})"; + WslDistroCombo.Items.Add(new ComboBoxItem { Content = label, Tag = d.Name }); + } + WslDistroCombo.SelectedIndex = 0; + WslHelpText.Text = ""; + } + private void ScheduleWorktreeProbe() { - if (IsRemoteMode) + if (!IsLocalMode) { WorktreesPanel.Visibility = Visibility.Collapsed; return; @@ -198,6 +230,8 @@ private async System.Threading.Tasks.Task ProbeSiblingWorktreesAsync(string fold } private bool IsRemoteMode => RemoteRadio?.IsChecked == true; + private bool IsWslMode => WslRadio?.IsChecked == true; + private bool IsLocalMode => !IsRemoteMode && !IsWslMode; private void AutoFillName() { @@ -212,6 +246,21 @@ private void AutoFillName() catch { } } } + else if (IsWslMode) + { + string distro = (WslDistroCombo.SelectedItem as ComboBoxItem)?.Tag as string ?? ""; + string folder = WslWorkingFolderBox.Text.Trim(); + string leaf = ""; + if (!string.IsNullOrEmpty(folder)) + { + string trimmed = folder.TrimEnd('/'); + int slash = trimmed.LastIndexOf('/'); + leaf = slash >= 0 ? trimmed[(slash + 1)..] : trimmed; + } + NameBox.Text = string.IsNullOrEmpty(leaf) + ? distro + : (string.IsNullOrEmpty(distro) ? leaf : $"{distro}: {leaf}"); + } else { if (!string.IsNullOrWhiteSpace(FolderBox.Text)) @@ -225,17 +274,20 @@ private void AutoFillName() private void SessionType_Changed(object sender, RoutedEventArgs e) { if (LocalPanel == null) return; - LocalPanel.Visibility = IsRemoteMode ? Visibility.Collapsed : Visibility.Visible; + LocalPanel.Visibility = IsLocalMode ? Visibility.Visible : Visibility.Collapsed; SshPanel.Visibility = IsRemoteMode ? Visibility.Visible : Visibility.Collapsed; + WslPanel.Visibility = IsWslMode ? Visibility.Visible : Visibility.Collapsed; // Profile combobox is local-only if (ProfilePanel != null && _profiles.Count > 0) - ProfilePanel.Visibility = IsRemoteMode ? Visibility.Collapsed : Visibility.Visible; + ProfilePanel.Visibility = IsLocalMode ? Visibility.Visible : Visibility.Collapsed; if (WorktreesPanel != null) { WorktreesPanel.Visibility = Visibility.Collapsed; _lastProbedFolder = null; } - CommandLabel.Text = IsRemoteMode ? "Remote Shell" : "Command"; + CommandLabel.Text = IsRemoteMode ? "Remote Shell" + : IsWslMode ? "Shell (inside WSL)" + : "Command"; NameBox.Text = ""; AutoFillName(); } @@ -321,9 +373,10 @@ private void ProfileCombo_SelectionChanged(object sender, SelectionChangedEventA private void Start_Click(object sender, RoutedEventArgs e) { IsRemote = IsRemoteMode; + IsWsl = IsWslMode; SessionName = NameBox.Text.Trim(); - if (!IsRemoteMode && WorktreesPanel.Visibility == Visibility.Visible) + if (IsLocalMode && WorktreesPanel.Visibility == Visibility.Visible) { AdditionalWorktreePaths = WorktreesList.Children.OfType() .Where(c => c.IsChecked == true) @@ -333,6 +386,33 @@ private void Start_Click(object sender, RoutedEventArgs e) .ToList(); } + if (IsWsl) + { + WslDistro = (WslDistroCombo.SelectedItem as ComboBoxItem)?.Tag as string ?? ""; + if (string.IsNullOrWhiteSpace(WslDistro)) + { + System.Windows.MessageBox.Show( + "Please select a WSL distro.", + "Distro required", MessageBoxButton.OK, MessageBoxImage.Warning); + WslDistroCombo.Focus(); + return; + } + + WslUser = WslUserBox.Text.Trim(); + WslWorkingFolder = WslWorkingFolderBox.Text.Trim(); + + var selectedTag = (CommandCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "bash"; + string raw = selectedTag == "custom" ? CustomArgsBox.Text.Trim() : selectedTag; + var (exe, args) = CommandLineSplitter.Split(raw); + SelectedCommand = string.IsNullOrEmpty(exe) ? "bash" : exe; + SelectedArgs = args; + + SelectedFolder = ""; + DialogResult = true; + Close(); + return; + } + if (IsRemote) { if (string.IsNullOrWhiteSpace(SshHostBox.Text)) diff --git a/tests/CodeShellManager.Tests/RunInstanceTests.cs b/tests/CodeShellManager.Tests/RunInstanceTests.cs index bd0f83a..a1b5a23 100644 --- a/tests/CodeShellManager.Tests/RunInstanceTests.cs +++ b/tests/CodeShellManager.Tests/RunInstanceTests.cs @@ -79,4 +79,32 @@ public void BuildPwshArgs_RoundTripsCommandLineViaBase64() string decoded = System.Text.Encoding.Unicode.GetString(System.Convert.FromBase64String(b64)); Assert.Equal(cmd, decoded); } + + [Fact] + public void BuildWslArgs_HappyPath_BuildsExpectedShape() + { + var p = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "Ubuntu", WslUser = "alice", + WslWorkingFolder = "/home/alice/proj", + }; + string args = RunInstance.BuildWslArgs(p, "cargo test"); + Assert.Equal("-d Ubuntu -u alice --cd /home/alice/proj -- bash -lc 'cargo test'", args); + } + + [Fact] + public void BuildWslArgs_NoUserOrFolder_OmitsFlags() + { + var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Debian" }; + string args = RunInstance.BuildWslArgs(p, "ls"); + Assert.Equal("-d Debian -- bash -lc 'ls'", args); + } + + [Fact] + public void BuildWslArgs_CommandLineWithApostrophe_IsEscaped() + { + var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu" }; + string args = RunInstance.BuildWslArgs(p, "echo it's me"); + Assert.Contains(@"bash -lc 'echo it'\''s me'", args); + } } diff --git a/tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs b/tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs new file mode 100644 index 0000000..8ff1315 --- /dev/null +++ b/tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using CodeShellManager.Models; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// State-file migration coverage. Legacy state.json predates the +/// enum and only carried IsRemote; the deserializer must still produce a session +/// with the right . +/// +public class ShellSessionMigrationTests +{ + [Fact] + public void Deserialize_LegacyIsRemoteTrue_PromotesKindToSsh() + { + // Hand-rolled to match what an older app version would have written — + // no `Kind` key, only `IsRemote`. + const string legacy = """ + { + "IsRemote": true, + "SshUser": "alice", + "SshHost": "dev.example.com", + "SshPort": 22 + } + """; + var s = JsonSerializer.Deserialize(legacy)!; + Assert.Equal(SessionKind.Ssh, s.Kind); + Assert.True(s.IsRemote); + Assert.Equal("alice", s.SshUser); + } + + [Fact] + public void Deserialize_LegacyIsRemoteFalse_KeepsKindLocal() + { + const string legacy = """{ "IsRemote": false, "WorkingFolder": "C:\\proj" }"""; + var s = JsonSerializer.Deserialize(legacy)!; + Assert.Equal(SessionKind.Local, s.Kind); + Assert.False(s.IsRemote); + } + + [Fact] + public void Deserialize_NewFormatWithKindWsl_LeavesIsRemoteFalse() + { + // StateService doesn't configure JsonStringEnumConverter, so enums round-trip + // as integers. SessionKind.Wsl == 2. + const string current = """ + { + "Kind": 2, + "WslDistro": "Ubuntu", + "WslWorkingFolder": "/home/alice/proj" + } + """; + var s = JsonSerializer.Deserialize(current)!; + Assert.Equal(SessionKind.Wsl, s.Kind); + Assert.False(s.IsRemote); + Assert.Equal("Ubuntu", s.WslDistro); + } + + [Fact] + public void Deserialize_BothKindAndLegacyIsRemote_KindWinsWhenKindIsWsl() + { + // Defensive: a file written by new code carries both IsRemote (computed, so false + // for Wsl) and Kind. Verify the setter never demotes a Wsl Kind back to Ssh. + const string mixed = """ + { + "Kind": 2, + "IsRemote": false, + "WslDistro": "Ubuntu" + } + """; + var s = JsonSerializer.Deserialize(mixed)!; + Assert.Equal(SessionKind.Wsl, s.Kind); + } + + [Fact] + public void Roundtrip_NewFormat_PreservesKind() + { + var original = new ShellSession + { + Kind = SessionKind.Wsl, + WslDistro = "Debian", + WslUser = "bob", + WslWorkingFolder = "/srv/app", + }; + string json = JsonSerializer.Serialize(original); + var revived = JsonSerializer.Deserialize(json)!; + Assert.Equal(SessionKind.Wsl, revived.Kind); + Assert.Equal("Debian", revived.WslDistro); + Assert.Equal("bob", revived.WslUser); + Assert.Equal("/srv/app", revived.WslWorkingFolder); + } +} diff --git a/tests/CodeShellManager.Tests/ShellSessionTests.cs b/tests/CodeShellManager.Tests/ShellSessionTests.cs index d53872b..13c6dc9 100644 --- a/tests/CodeShellManager.Tests/ShellSessionTests.cs +++ b/tests/CodeShellManager.Tests/ShellSessionTests.cs @@ -90,4 +90,104 @@ public void BuildSshArgs_EmptyHost_ThrowsInvalidOperationException() }; Assert.Throws(() => s.BuildSshArgs()); } + + [Fact] + public void IsRemote_SetTrue_PromotesKindToSsh() + { + var s = new ShellSession { IsRemote = true }; + Assert.Equal(SessionKind.Ssh, s.Kind); + Assert.True(s.IsRemote); + } + + [Fact] + public void IsRemote_GetterTrueOnlyForSsh() + { + Assert.False(new ShellSession { Kind = SessionKind.Local }.IsRemote); + Assert.True(new ShellSession { Kind = SessionKind.Ssh }.IsRemote); + Assert.False(new ShellSession { Kind = SessionKind.Wsl }.IsRemote); + } + + [Fact] + public void BuildWslArgs_HappyPath_BuildsExpectedShape() + { + var s = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "Ubuntu", WslUser = "alice", + WslWorkingFolder = "/home/alice/proj", Command = "claude", + }; + Assert.Equal("-d Ubuntu -u alice --cd /home/alice/proj -- bash -lc \"claude\"", + s.BuildWslArgs()); + } + + [Fact] + public void BuildWslArgs_NoUser_OmitsUserFlag() + { + var s = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "Debian", + WslWorkingFolder = "/srv", Command = "bash", + }; + Assert.Equal("-d Debian --cd /srv -- bash -lc \"bash\"", s.BuildWslArgs()); + } + + [Fact] + public void BuildWslArgs_NoWorkingFolder_OmitsCdFlag() + { + var s = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "Ubuntu", Command = "bash", + }; + Assert.Equal("-d Ubuntu -- bash -lc \"bash\"", s.BuildWslArgs()); + } + + [Fact] + public void BuildWslArgs_ArgsAppendedToShell() + { + var s = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "Ubuntu", + Command = "claude", Args = "--continue", + }; + Assert.Contains("bash -lc \"claude --continue\"", s.BuildWslArgs()); + } + + [Fact] + public void BuildWslArgs_EmptyDistro_ThrowsInvalidOperationException() + { + var s = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "", Command = "bash" }; + Assert.Throws(() => s.BuildWslArgs()); + } + + [Fact] + public void FullCommandLine_Wsl_StartsWithWslExe() + { + var s = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "Ubuntu", + Command = "claude", + }; + Assert.StartsWith("wsl.exe ", s.FullCommandLine); + } + + [Fact] + public void DefaultDisplayName_WslWithFolder_IsDistroAndLeaf() + { + var s = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "Ubuntu", + WslWorkingFolder = "/home/alice/proj", + }; + Assert.Equal("Ubuntu: proj", s.DefaultDisplayName); + } + + [Fact] + public void AccentKey_Wsl_DistinctFromLocal() + { + var wsl = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "Ubuntu", WslWorkingFolder = "/proj", + }; + var local = new ShellSession { WorkingFolder = "/proj" }; + Assert.NotEqual(wsl.AccentKey, local.AccentKey); + } } diff --git a/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs b/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs new file mode 100644 index 0000000..e2e54eb --- /dev/null +++ b/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs @@ -0,0 +1,85 @@ +using System.Linq; +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +public class WslDiscoveryServiceTests +{ + // Sample copied from `wsl -l -v` on a host with two distros installed. The + // leading whitespace in front of "NAME" and the spacing are intentional — + // wsl pads columns with spaces, never tabs. + private const string SampleOutput = + " NAME STATE VERSION\n" + + "* Ubuntu Running 2\n" + + " Debian Stopped 2\n"; + + [Fact] + public void Parse_TwoDistros_ReturnsBoth() + { + var result = WslDiscoveryService.Parse(SampleOutput); + Assert.Equal(2, result.Count); + } + + [Fact] + public void Parse_MarksDefaultDistro() + { + var result = WslDiscoveryService.Parse(SampleOutput); + Assert.Single(result, d => d.IsDefault); + Assert.Equal("Ubuntu", result[0].Name); // default sorted first + } + + [Fact] + public void Parse_ParsesVersionAndState() + { + var result = WslDiscoveryService.Parse(SampleOutput); + var ubuntu = result.Single(d => d.Name == "Ubuntu"); + Assert.Equal(2, ubuntu.Version); + Assert.Equal("Running", ubuntu.State); + } + + [Fact] + public void Parse_EmptyInput_ReturnsEmpty() + { + Assert.Empty(WslDiscoveryService.Parse("")); + Assert.Empty(WslDiscoveryService.Parse(" \n")); + } + + [Fact] + public void Parse_HeaderOnly_ReturnsEmpty() + { + Assert.Empty(WslDiscoveryService.Parse(" NAME STATE VERSION\n")); + } + + [Fact] + public void Parse_NonDefaultThenDefault_OrdersDefaultFirst() + { + const string reversed = + " NAME STATE VERSION\n" + + " Debian Stopped 2\n" + + "* Ubuntu Running 2\n"; + var result = WslDiscoveryService.Parse(reversed); + Assert.Equal("Ubuntu", result[0].Name); + Assert.True(result[0].IsDefault); + } + + [Fact] + public void ToUncPath_HappyPath() + { + Assert.Equal(@"\\wsl$\Ubuntu\home\alice\proj", + WslDiscoveryService.ToUncPath("Ubuntu", "/home/alice/proj")); + } + + [Fact] + public void ToUncPath_NoLinuxPath_ReturnsDistroRoot() + { + Assert.Equal(@"\\wsl$\Ubuntu", + WslDiscoveryService.ToUncPath("Ubuntu", "")); + } + + [Fact] + public void ToUncPath_NoDistro_ReturnsEmpty() + { + Assert.Equal("", WslDiscoveryService.ToUncPath("", "/home/x")); + } +} From 60de1abd241085c9fca160cf13f8aa4c7e5203db Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 13:02:29 +0200 Subject: [PATCH 02/24] feat(new-session): folder picker for WSL working folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the local Browse… button on the WSL panel. Opens FolderBrowserDialog rooted at \\wsl$\ (the WSL filesystem appears there as a native UNC share); on result, parses the picked path back to a Linux path and, if the user drilled into a different distro than the combo had, updates the combo too. ParseWslUncPath is extracted as an internal static so it can be covered headlessly via InternalsVisibleTo — accepts both the \\wsl$\ and the newer \\wsl.localhost\ prefixes, plus forward-slash variants. --- .../Views/NewSessionDialog.xaml | 15 +++- .../Views/NewSessionDialog.xaml.cs | 69 +++++++++++++++++++ .../NewSessionDialogTests.cs | 33 +++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 tests/CodeShellManager.Tests/NewSessionDialogTests.cs diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml b/src/CodeShellManager/Views/NewSessionDialog.xaml index 6817a83..969e196 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml @@ -251,9 +251,18 @@ ToolTip="Optional WSL user (-u). Leave blank for the distro's default user."/> - + + + + + + + + private void BrowseWslFolder_Click(object sender, RoutedEventArgs e) + { + string selectedDistro = (WslDistroCombo.SelectedItem as ComboBoxItem)?.Tag as string ?? ""; + string seed = string.IsNullOrEmpty(selectedDistro) ? @"\\wsl$" : $@"\\wsl$\{selectedDistro}"; + + using var dialog = new System.Windows.Forms.FolderBrowserDialog + { + Description = "Select Linux working folder (inside WSL)", + UseDescriptionForTitle = true, + SelectedPath = seed, + }; + if (dialog.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; + + var (distro, linuxPath) = ParseWslUncPath(dialog.SelectedPath); + if (string.IsNullOrEmpty(distro)) + { + // User picked something outside `\\wsl$\\` — fall back to just + // setting the raw path so we don't silently throw away their selection. + WslWorkingFolderBox.Text = dialog.SelectedPath; + } + else + { + // If they drilled into a different distro than the combo had, switch the combo too. + if (!string.Equals(distro, selectedDistro, StringComparison.OrdinalIgnoreCase)) + { + foreach (var item in WslDistroCombo.Items.OfType()) + { + if (string.Equals(item.Tag as string, distro, StringComparison.OrdinalIgnoreCase)) + { + WslDistroCombo.SelectedItem = item; + break; + } + } + } + WslWorkingFolderBox.Text = linuxPath; + } + AutoFillName(); + } + + /// + /// Splits a WSL UNC path (\\wsl$\Ubuntu\home\alice or the + /// \\wsl.localhost\ variant) into (distro, linux-path). Returns empty + /// strings when the input isn't a recognizable WSL UNC. + /// + internal static (string distro, string linuxPath) ParseWslUncPath(string unc) + { + if (string.IsNullOrWhiteSpace(unc)) return ("", ""); + string normalized = unc.Replace('/', '\\').TrimEnd('\\'); + string[] prefixes = { @"\\wsl$\", @"\\wsl.localhost\" }; + foreach (var prefix in prefixes) + { + if (!normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; + string rest = normalized[prefix.Length..]; + if (string.IsNullOrEmpty(rest)) return ("", ""); + int slash = rest.IndexOf('\\'); + string distro = slash < 0 ? rest : rest[..slash]; + string linuxRest = slash < 0 ? "" : rest[(slash + 1)..]; + string linuxPath = string.IsNullOrEmpty(linuxRest) ? "" : "/" + linuxRest.Replace('\\', '/'); + return (distro, linuxPath); + } + return ("", ""); + } + private void CommandCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (CustomArgsPanel == null) return; diff --git a/tests/CodeShellManager.Tests/NewSessionDialogTests.cs b/tests/CodeShellManager.Tests/NewSessionDialogTests.cs new file mode 100644 index 0000000..f48addd --- /dev/null +++ b/tests/CodeShellManager.Tests/NewSessionDialogTests.cs @@ -0,0 +1,33 @@ +using CodeShellManager.Views; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// Headless coverage for the bits of that don't +/// require a window (parsing helpers). UI-level behavior lives in UITests. +/// +public class NewSessionDialogTests +{ + [Theory] + [InlineData(@"\\wsl$\Ubuntu\home\alice\proj", "Ubuntu", "/home/alice/proj")] + [InlineData(@"\\wsl.localhost\Debian\srv\app", "Debian", "/srv/app")] + [InlineData(@"\\wsl$\Ubuntu", "Ubuntu", "")] + [InlineData(@"\\wsl$\Ubuntu\", "Ubuntu", "")] + [InlineData(@"C:\proj", "", "")] + [InlineData("", "", "")] + public void ParseWslUncPath_KnownShapes(string unc, string expectedDistro, string expectedLinux) + { + var (distro, linuxPath) = NewSessionDialog.ParseWslUncPath(unc); + Assert.Equal(expectedDistro, distro); + Assert.Equal(expectedLinux, linuxPath); + } + + [Fact] + public void ParseWslUncPath_ForwardSlashes_Normalized() + { + var (distro, linuxPath) = NewSessionDialog.ParseWslUncPath(@"//wsl$/Ubuntu/home/alice"); + Assert.Equal("Ubuntu", distro); + Assert.Equal("/home/alice", linuxPath); + } +} From 4a499531569a182718409addf3935cc3ce43f89c Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 13:05:34 +0200 Subject: [PATCH 03/24] feat(sessions): add IsWsl convenience predicate on ShellSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symmetry with IsRemote (which remains the SSH predicate). Keeps the "remote = ssh" convention intact — just gives WSL its own one-liner so call sites don't have to spell out `Kind == SessionKind.Wsl`. --- src/CodeShellManager/Models/ShellSession.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/CodeShellManager/Models/ShellSession.cs b/src/CodeShellManager/Models/ShellSession.cs index 6855d1f..30882a1 100644 --- a/src/CodeShellManager/Models/ShellSession.cs +++ b/src/CodeShellManager/Models/ShellSession.cs @@ -51,7 +51,7 @@ public class ShellSession // SSH / remote session fields /// - /// Legacy SSH flag — true iff is . + /// SSH flag — true iff is . /// Kept as a property (not just a computed getter) so old state.json files with /// "IsRemote": true and no Kind key still migrate cleanly on /// deserialization. The setter only promotes Local → Ssh; it never clears @@ -63,6 +63,9 @@ public bool IsRemote get => Kind == SessionKind.Ssh; set { if (value && Kind == SessionKind.Local) Kind = SessionKind.Ssh; } } + + /// True iff this session runs inside a WSL distro via wsl.exe. + public bool IsWsl => Kind == SessionKind.Wsl; public string SshUser { get; set; } = ""; public string SshHost { get; set; } = ""; public int SshPort { get; set; } = 22; From bc4b3e6332deb5214d8eff2d1ac953cf48be13fd Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 13:21:50 +0200 Subject: [PATCH 04/24] fix(git): route GitService through wsl.exe for WSL UNC working folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git for Windows can't reliably operate on \\wsl$\\... paths — the dubious-ownership check refuses to run, and "not a git repo" pops on perfectly valid repos. Detecting the WSL UNC in the GitService funnel and dispatching to `wsl.exe -d -- git -C ` sidesteps both. Two small translators make the seam invisible: TranslateUncArgsToLinux: rewrites \\wsl$\\foo tokens in the arg string to /foo before invocation, so callers can keep passing Windows-shaped paths (e.g. `worktree add `). TranslateLinuxPathsToUnc: walks git stdout for absolute Linux paths (rev-parse --git-common-dir, worktree list --porcelain) and rewrites them back to UNC, so the rest of the app sees uniform paths. Both translators are conservative about what they touch — `refs/heads/foo` and `M README.md` are passed through untouched per tests. --- src/CodeShellManager/Services/GitService.cs | 111 ++++++++++++++++++ .../GitServiceWslRoutingTests.cs | 88 ++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs diff --git a/src/CodeShellManager/Services/GitService.cs b/src/CodeShellManager/Services/GitService.cs index 9d50ff4..a64ed02 100644 --- a/src/CodeShellManager/Services/GitService.cs +++ b/src/CodeShellManager/Services/GitService.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; namespace CodeShellManager.Services; @@ -166,6 +168,13 @@ public static async Task> ListBranchesAsync(string folderP private static async Task<(string stdout, string stderr, int exit)> RunGitFullAsync( string workingDir, string arguments, int timeoutMs) { + // WSL working folders (\\wsl$\\…) get routed through wsl.exe so git + // runs inside the distro. Git for Windows trips on WSL UNCs (dubious-ownership + // checks, .git symlink quirks) and reports "not a git repo" for valid repos. + var (wslDistro, linuxPath) = TryParseWslUnc(workingDir); + if (wslDistro != null) + return await RunGitInWslAsync(wslDistro, linuxPath, arguments, timeoutMs); + var psi = new ProcessStartInfo("git") { Arguments = $"-C \"{workingDir}\" {arguments}", @@ -193,4 +202,106 @@ public static async Task> ListBranchesAsync(string folderP string stderr = errTask.IsCompletedSuccessfully ? errTask.Result : ""; return (stdout, stderr, process.HasExited ? process.ExitCode : -1); } + + /// + /// Runs wsl.exe -d <distro> -- git -C <linuxPath> <arguments>. + /// Translates any WSL UNC paths in to Linux form + /// before invocation (so things like worktree add "\\wsl$\Ubuntu\…" reach + /// git as a normal Linux path), and translates absolute Linux paths in stdout + /// back to UNC form so callers receive Windows-shaped paths. + /// + private static async Task<(string stdout, string stderr, int exit)> RunGitInWslAsync( + string distro, string linuxPath, string arguments, int timeoutMs) + { + string translatedArgs = TranslateUncArgsToLinux(arguments, distro); + // Use double quotes around the cwd — wsl.exe + Linux git both accept them and + // it sidesteps the apostrophe-in-path footgun that single quotes would have. + string cwd = string.IsNullOrEmpty(linuxPath) ? "/" : linuxPath; + string args = $"-d {distro} -- git -C \"{cwd}\" {translatedArgs}"; + + var psi = new ProcessStartInfo("wsl.exe") + { + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + + using var process = Process.Start(psi); + if (process is null) return ("", "", -1); + + var outTask = process.StandardOutput.ReadToEndAsync(); + var errTask = process.StandardError.ReadToEndAsync(); + var bothTask = Task.WhenAll(outTask, errTask); + var completed = await Task.WhenAny(bothTask, Task.Delay(timeoutMs)); + if (completed != bothTask) { try { process.Kill(); } catch { } } + try { await process.WaitForExitAsync(); } catch { } + + string stdout = outTask.IsCompletedSuccessfully ? outTask.Result : ""; + string stderr = errTask.IsCompletedSuccessfully ? errTask.Result : ""; + stdout = TranslateLinuxPathsToUnc(stdout, distro); + return (stdout, stderr, process.HasExited ? process.ExitCode : -1); + } + + /// + /// Detects a \\wsl$\<distro>\… or \\wsl.localhost\<distro>\… + /// path and splits it into (distro, linux-path). Returns (null, "") otherwise. + /// + internal static (string? distro, string linuxPath) TryParseWslUnc(string path) + { + if (string.IsNullOrWhiteSpace(path)) return (null, ""); + string normalized = path.Replace('/', '\\').TrimEnd('\\'); + string[] prefixes = { @"\\wsl$\", @"\\wsl.localhost\" }; + foreach (var prefix in prefixes) + { + if (!normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; + string rest = normalized[prefix.Length..]; + if (string.IsNullOrEmpty(rest)) return (null, ""); + int slash = rest.IndexOf('\\'); + string distro = slash < 0 ? rest : rest[..slash]; + string linuxRest = slash < 0 ? "" : rest[(slash + 1)..]; + string linuxPath = string.IsNullOrEmpty(linuxRest) ? "/" : "/" + linuxRest.Replace('\\', '/'); + return (distro, linuxPath); + } + return (null, ""); + } + + /// + /// Replaces WSL UNC tokens in a git arg string with their Linux equivalents. + /// Only translates UNCs that belong to — a UNC for a + /// different distro is passed through unchanged (so the caller sees the eventual + /// "no such directory" error rather than silently aiming at the wrong tree). + /// + internal static string TranslateUncArgsToLinux(string arguments, string distro) + { + if (string.IsNullOrEmpty(arguments)) return arguments; + // Match \\wsl$\\ or \\wsl.localhost\\; \ is + // greedy up to the next quote/space (anything that would terminate a shell token). + var pattern = $@"\\\\wsl(?:\$|\.localhost)\\{Regex.Escape(distro)}(\\[^""\s]*)?"; + return Regex.Replace(arguments, pattern, m => + { + string tail = m.Groups[1].Value; + return string.IsNullOrEmpty(tail) ? "/" : tail.Replace('\\', '/'); + }, RegexOptions.IgnoreCase); + } + + /// + /// Replaces absolute Linux paths in (typically git stdout) + /// with \\wsl$\<distro>\… equivalents so callers see Windows-shaped + /// paths. Conservative — only matches tokens at start-of-line or after whitespace + /// to avoid mangling text that happens to contain a slash. + /// + internal static string TranslateLinuxPathsToUnc(string text, string distro) + { + if (string.IsNullOrEmpty(text)) return text; + return Regex.Replace(text, @"(^|[\s=:])(/[^\s'""<>|]+)", m => + { + string linuxPath = m.Groups[2].Value; + string unc = $@"\\wsl$\{distro}" + linuxPath.Replace('/', '\\'); + return m.Groups[1].Value + unc; + }, RegexOptions.Multiline); + } } diff --git a/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs b/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs new file mode 100644 index 0000000..3281996 --- /dev/null +++ b/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs @@ -0,0 +1,88 @@ +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// Headless coverage for the WSL routing helpers in GitService. The live +/// wsl.exe dispatch can't run on every test host; these cover the path/arg +/// translation that has to be exactly right for routing to land in the +/// correct place. +/// +public class GitServiceWslRoutingTests +{ + [Theory] + [InlineData(@"\\wsl$\Ubuntu\home\alice", "Ubuntu", "/home/alice")] + [InlineData(@"\\wsl.localhost\Debian\srv\app", "Debian", "/srv/app")] + [InlineData(@"\\wsl$\Ubuntu", "Ubuntu", "/")] + [InlineData(@"C:\proj", null, "")] + [InlineData("", null, "")] + public void TryParseWslUnc_KnownShapes(string path, string? expectedDistro, string expectedLinux) + { + var (distro, linuxPath) = GitService.TryParseWslUnc(path); + Assert.Equal(expectedDistro, distro); + Assert.Equal(expectedLinux, linuxPath); + } + + [Fact] + public void TranslateUncArgsToLinux_MatchingDistro_Substitutes() + { + string args = "worktree add \"\\\\wsl$\\Ubuntu\\home\\alice\\proj-foo\" main"; + string translated = GitService.TranslateUncArgsToLinux(args, "Ubuntu"); + Assert.Contains("/home/alice/proj-foo", translated); + Assert.DoesNotContain(@"\\wsl$\Ubuntu", translated); + } + + [Fact] + public void TranslateUncArgsToLinux_DifferentDistro_LeftAlone() + { + // We're running git inside Ubuntu — a UNC pointing at Debian is a real + // mistake and should NOT be silently rewritten to look like a local path. + string args = @"worktree add \\wsl$\Debian\home\alice\proj main"; + string translated = GitService.TranslateUncArgsToLinux(args, "Ubuntu"); + Assert.Equal(args, translated); + } + + [Fact] + public void TranslateUncArgsToLinux_NoUncs_Passthrough() + { + string args = "branch --show-current"; + Assert.Equal(args, GitService.TranslateUncArgsToLinux(args, "Ubuntu")); + } + + [Fact] + public void TranslateLinuxPathsToUnc_RevParseOutput() + { + string raw = "/home/alice/proj/.git\n"; + string translated = GitService.TranslateLinuxPathsToUnc(raw, "Ubuntu"); + Assert.Contains(@"\\wsl$\Ubuntu\home\alice\proj\.git", translated); + } + + [Fact] + public void TranslateLinuxPathsToUnc_WorktreeListPorcelain() + { + // Real-ish output: only the `worktree /…` lines carry abs paths; the rest + // (HEAD sha, refs/heads/x) must NOT be mangled. + string raw = "worktree /home/alice/proj\nHEAD abc123\nbranch refs/heads/main\n"; + string translated = GitService.TranslateLinuxPathsToUnc(raw, "Ubuntu"); + Assert.Contains(@"worktree \\wsl$\Ubuntu\home\alice\proj", translated); + Assert.Contains("HEAD abc123", translated); + Assert.Contains("branch refs/heads/main", translated); + } + + [Fact] + public void TranslateLinuxPathsToUnc_BranchNameWithSlash_NotMangled() + { + // refs/heads/feature/foo starts with 'r', not '/' — must pass through. + string raw = "feature/wsl-sessions\n"; + Assert.Equal(raw, GitService.TranslateLinuxPathsToUnc(raw, "Ubuntu")); + } + + [Fact] + public void TranslateLinuxPathsToUnc_StatusPorcelain_Untouched() + { + // Each "M file" / "?? new" line has no leading slash and shouldn't change. + string raw = "M README.md\n?? new.txt\n"; + Assert.Equal(raw, GitService.TranslateLinuxPathsToUnc(raw, "Ubuntu")); + } +} From fe2b3d0f82fbd52a02b268c7ab0b75d0000dc8a6 Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 13:24:42 +0200 Subject: [PATCH 05/24] feat(new-session): WSL browse picker opens at the distro's home folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three improvements bundled because they're all about the picker landing somewhere useful: - Browse seed = the user's home inside the distro, resolved via `wsl -d [-u ] -- sh -c "cd ~ && pwd"` and cached per (distro, user) so repeated clicks don't re-shell. - Also set FolderBrowserDialog.InitialDirectory in addition to SelectedPath. SelectedPath alone left the COM dialog rooted at the user's last folder (typically Documents) and only pre-typed the UNC in the entry field — clicking Browse felt broken. - The WSL panel now has a visible "User (optional)" column header. Previously the user textbox was identifiable only by tooltip, which read as an empty unlabeled box. --- .../Services/WslDiscoveryService.cs | 53 +++++++++++++++++++ .../Views/NewSessionDialog.xaml | 13 +++-- .../Views/NewSessionDialog.xaml.cs | 23 +++++++- 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/CodeShellManager/Services/WslDiscoveryService.cs b/src/CodeShellManager/Services/WslDiscoveryService.cs index 0681b4b..4d2a564 100644 --- a/src/CodeShellManager/Services/WslDiscoveryService.cs +++ b/src/CodeShellManager/Services/WslDiscoveryService.cs @@ -115,6 +115,59 @@ internal static IReadOnlyList Parse(string raw) .ToList(); } + /// + /// Resolves the home directory inside a WSL distro for the given user (or the distro's + /// default user when is null/empty). Cached per (distro, user) — + /// shells out once via wsl -d <distro> [-u <user>] -- sh -c "cd ~ && pwd" + /// then returns the cached value on subsequent calls. Returns null on failure + /// (WSL not running, command timeout, or non-zero exit). + /// + public static async Task GetDistroHomeAsync(string distro, string? user = null) + { + if (string.IsNullOrWhiteSpace(distro)) return null; + string normalizedUser = user?.Trim() ?? ""; + string key = $"{distro}|{normalizedUser}"; + lock (_homeCache) + { + if (_homeCache.TryGetValue(key, out var cached)) return cached; + } + + try + { + string args = $"-d {distro}"; + if (!string.IsNullOrEmpty(normalizedUser)) args += $" -u {normalizedUser}"; + args += " -- sh -c \"cd ~ && pwd\""; + + var psi = new ProcessStartInfo("wsl.exe") + { + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + using var process = Process.Start(psi); + if (process is null) return null; + + var outTask = process.StandardOutput.ReadToEndAsync(); + var completed = await Task.WhenAny(outTask, Task.Delay(3000)); + if (completed != outTask) { try { process.Kill(); } catch { } return null; } + try { await process.WaitForExitAsync(); } catch { } + if (process.ExitCode != 0) return null; + + string home = outTask.Result.Trim(); + if (string.IsNullOrEmpty(home)) return null; + lock (_homeCache) _homeCache[key] = home; + return home; + } + catch (Win32Exception) { return null; } + catch (FileNotFoundException) { return null; } + } + + private static readonly Dictionary _homeCache = new(); + /// /// Converts a WSL distro + Linux-style path to the Windows UNC view of that path /// (\\wsl$\Ubuntu\home\alice). Used by GitService and PseudoTerminal's diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml b/src/CodeShellManager/Views/NewSessionDialog.xaml index 969e196..f35d6bd 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml @@ -235,19 +235,26 @@ - + + + + + + diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index 2f90854..4075ba5 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -313,15 +313,20 @@ private void BrowseFolder_Click(object sender, RoutedEventArgs e) /// Linux working-folder box update to match — so they can also switch distros /// by drilling into a different one in the dialog. /// - private void BrowseWslFolder_Click(object sender, RoutedEventArgs e) + private async void BrowseWslFolder_Click(object sender, RoutedEventArgs e) { string selectedDistro = (WslDistroCombo.SelectedItem as ComboBoxItem)?.Tag as string ?? ""; - string seed = string.IsNullOrEmpty(selectedDistro) ? @"\\wsl$" : $@"\\wsl$\{selectedDistro}"; + string seed = await ComputeWslBrowseSeedAsync(selectedDistro, WslUserBox.Text.Trim()); + // Both InitialDirectory AND SelectedPath are needed: SelectedPath alone leaves + // the COM file dialog rooted at the user's last location (often Documents) for + // UNC paths it can't resolve to a shell namespace folder. Setting both makes the + // dialog navigate into the WSL share. using var dialog = new System.Windows.Forms.FolderBrowserDialog { Description = "Select Linux working folder (inside WSL)", UseDescriptionForTitle = true, + InitialDirectory = seed, SelectedPath = seed, }; if (dialog.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; @@ -352,6 +357,20 @@ private void BrowseWslFolder_Click(object sender, RoutedEventArgs e) AutoFillName(); } + /// + /// Seed path for the WSL folder picker. Prefers the user's home directory inside + /// the distro (resolved via cd ~ && pwd) so picking lands somewhere + /// useful; falls back to the distro root when WSL isn't reachable, and to + /// \\wsl$ when no distro is selected yet. + /// + private async System.Threading.Tasks.Task ComputeWslBrowseSeedAsync(string distro, string user) + { + if (string.IsNullOrEmpty(distro)) return @"\\wsl$"; + string? home = await WslDiscoveryService.GetDistroHomeAsync(distro, user); + if (string.IsNullOrEmpty(home)) return $@"\\wsl$\{distro}"; + return WslDiscoveryService.ToUncPath(distro, home); + } + /// /// Splits a WSL UNC path (\\wsl$\Ubuntu\home\alice or the /// \\wsl.localhost\ variant) into (distro, linux-path). Returns empty From ad657d1d875d986f1a89b7afe7e510370293343f Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 13:25:21 +0200 Subject: [PATCH 06/24] fix(new-session): re-fire name suggestion when distro / folder changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The early-return guard "if NameBox not empty, leave alone" was too greedy: once we auto-filled the name from the first context, it preserved that stale value forever — switching distros wouldn't update the suggestion. Track our own last auto-fill so the guard fires only on truly-user-edited content. Empty box and "still shows our previous suggestion" are both treated as free-to-overwrite; anything else is preserved as user input. --- .../Views/NewSessionDialog.xaml.cs | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index 4075ba5..163d780 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -63,6 +63,14 @@ public partial class NewSessionDialog : Window private readonly System.Windows.Threading.DispatcherTimer _worktreeDebounce; private System.Threading.CancellationTokenSource? _worktreeProbeCts; private string? _lastProbedFolder; + /// + /// What we last auto-filled into . AutoFillName uses this + /// to tell "the user hasn't typed anything custom" from "the user has". When + /// the box equals this value (or is empty), we're free to overwrite it when + /// the source context (folder / distro / host) changes. Anything else means + /// the user has edited it and we must not stomp. + /// + private string _lastAutoFilledName = ""; public NewSessionDialog( string defaultFolder = "", @@ -235,14 +243,18 @@ private async System.Threading.Tasks.Task ProbeSiblingWorktreesAsync(string fold private void AutoFillName() { - if (!string.IsNullOrWhiteSpace(NameBox.Text)) return; + // Allow overwrite when the box is empty OR still holds our last auto-fill. + // Anything else means the user typed something — leave it alone. + if (!string.IsNullOrWhiteSpace(NameBox.Text) && NameBox.Text != _lastAutoFilledName) + return; + string suggested = ""; if (IsRemoteMode) { var raw = SshHostBox.Text.Trim(); if (!string.IsNullOrWhiteSpace(raw)) { - try { NameBox.Text = raw.Split(':')[0]; } + try { suggested = raw.Split(':')[0]; } catch { } } } @@ -257,7 +269,7 @@ private void AutoFillName() int slash = trimmed.LastIndexOf('/'); leaf = slash >= 0 ? trimmed[(slash + 1)..] : trimmed; } - NameBox.Text = string.IsNullOrEmpty(leaf) + suggested = string.IsNullOrEmpty(leaf) ? distro : (string.IsNullOrEmpty(distro) ? leaf : $"{distro}: {leaf}"); } @@ -265,10 +277,13 @@ private void AutoFillName() { if (!string.IsNullOrWhiteSpace(FolderBox.Text)) { - try { NameBox.Text = Path.GetFileName(FolderBox.Text.TrimEnd('/', '\\')); } + try { suggested = Path.GetFileName(FolderBox.Text.TrimEnd('/', '\\')) ?? ""; } catch { } } } + + NameBox.Text = suggested; + _lastAutoFilledName = suggested; } private void SessionType_Changed(object sender, RoutedEventArgs e) From e82f6ae281386bc75aefff340cf4b9ba4a1ac5b0 Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 13:26:43 +0200 Subject: [PATCH 07/24] feat(new-session): inherit WSL distro/user/folder from the parent session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click → "New session here" on a WSL session used to open the dialog in Local mode with the parent's `\\wsl$\…` UNC pre-filled into the working-folder textbox — a layer-cake of subtle wrongness. Now the dialog auto-selects the WSL radio, pre-fills user and Linux working folder from the parent in the constructor, and remembers the parent's distro so PopulateWslDistrosAsync can mark the right combo entry as selected once the async distro list lands. --- src/CodeShellManager/MainWindow.xaml.cs | 3 +- .../Views/NewSessionDialog.xaml.cs | 31 +++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 38e0658..7c9ef9c 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -406,7 +406,8 @@ private void OpenNewSessionDialogCore(string defaultFolder, SessionViewModel? pa defaultCommand: parent?.Session.Command, defaultArgs: parent?.Session.Args, defaultName: null, - recentlyClosed: _vm.RecentlyClosed) + recentlyClosed: _vm.RecentlyClosed, + defaultSourceSession: parent?.Session) { Owner = this }; diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index 163d780..0bf4a1a 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -71,6 +71,11 @@ public partial class NewSessionDialog : Window /// the user has edited it and we must not stomp. /// private string _lastAutoFilledName = ""; + /// + /// Distro name we want PopulateWslDistrosAsync to pre-select once the combo + /// finishes loading. Empty = use the default (first / system default distro). + /// + private readonly string _preselectWslDistro = ""; public NewSessionDialog( string defaultFolder = "", @@ -79,11 +84,13 @@ public NewSessionDialog( string? defaultCommand = null, string? defaultArgs = null, string? defaultName = null, - IReadOnlyList? recentlyClosed = null) + IReadOnlyList? recentlyClosed = null, + ShellSession? defaultSourceSession = null) { InitializeComponent(); FolderBox.Text = defaultFolder; _profiles = profiles ?? Array.Empty(); + _preselectWslDistro = defaultSourceSession?.IsWsl == true ? defaultSourceSession.WslDistro : ""; var customItem = CommandCombo.Items[0]; CommandCombo.Items.Clear(); @@ -138,6 +145,17 @@ public NewSessionDialog( WslDistroCombo.SelectionChanged += (_, _) => AutoFillName(); WslWorkingFolderBox.TextChanged += (_, _) => AutoFillName(); + // Inherit WSL parent: when a user right-clicks a WSL session and picks + // "New session here", default the new dialog to WSL mode with the same + // distro/user/folder pre-filled. The combo selection happens later in + // PopulateWslDistrosAsync (it's async-populated on Loaded). + if (defaultSourceSession?.IsWsl == true) + { + WslRadio.IsChecked = true; + WslUserBox.Text = defaultSourceSession.WslUser ?? ""; + WslWorkingFolderBox.Text = defaultSourceSession.WslWorkingFolder ?? ""; + } + Loaded += async (_, _) => { if (IsLocalMode && !string.IsNullOrWhiteSpace(FolderBox.Text)) @@ -160,12 +178,19 @@ private async System.Threading.Tasks.Task PopulateWslDistrosAsync() WslHelpText.Text = "No WSL distros found. Install WSL from the Microsoft Store, then re-open this dialog."; return; } + ComboBoxItem? preselectMatch = null; foreach (var d in distros) { string label = d.IsDefault ? $"{d.Name} (default, v{d.Version})" : $"{d.Name} (v{d.Version})"; - WslDistroCombo.Items.Add(new ComboBoxItem { Content = label, Tag = d.Name }); + var item = new ComboBoxItem { Content = label, Tag = d.Name }; + WslDistroCombo.Items.Add(item); + if (!string.IsNullOrEmpty(_preselectWslDistro) + && string.Equals(d.Name, _preselectWslDistro, StringComparison.OrdinalIgnoreCase)) + { + preselectMatch = item; + } } - WslDistroCombo.SelectedIndex = 0; + WslDistroCombo.SelectedItem = preselectMatch ?? WslDistroCombo.Items[0]; WslHelpText.Text = ""; } From 736dca4b009050dfa54b4a6f4e1545f66cce56a8 Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 13:27:32 +0200 Subject: [PATCH 08/24] feat(menu): "Open WSL console here" for WSL sessions PowerShell-here on a WSL parent still opens a Windows shell (PS handles the UNC path well enough as cwd, so it works), but the natural shell to ask for from a WSL session is bash inside the same distro. Add a sibling menu item that creates a fresh WSL session pointed at the parent's distro / user / Linux folder. Only shown when the parent IsWsl. --- src/CodeShellManager/MainWindow.xaml.cs | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 7c9ef9c..5561a8f 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -2779,6 +2779,13 @@ private System.Windows.Controls.ContextMenu BuildSessionContextMenu(SessionViewM var psItem = new System.Windows.Controls.MenuItem { Header = "Open PowerShell here" }; psItem.Click += (_, _) => LaunchPowerShellInFolder(vm.WorkingFolder, vm.GroupId); menu.Items.Add(psItem); + + if (vm.Session.IsWsl) + { + var wslConsoleItem = new System.Windows.Controls.MenuItem { Header = "Open WSL console here" }; + wslConsoleItem.Click += (_, _) => LaunchWslConsoleFromSession(vm.Session); + menu.Items.Add(wslConsoleItem); + } } menu.Items.Add(new System.Windows.Controls.Separator()); @@ -4565,6 +4572,27 @@ private void LaunchPowerShellInFolder(string workingFolder, string groupId) _ = LaunchSessionAsync(session); } + /// + /// WSL counterpart of : spawns a bare bash + /// session inside the same distro + Linux folder as . + /// Used by the "Open WSL console here" context-menu item. + /// + private void LaunchWslConsoleFromSession(Models.ShellSession parent) + { + if (!parent.IsWsl) return; + string leaf = string.IsNullOrEmpty(parent.WslWorkingFolder) + ? parent.WslDistro + : System.IO.Path.GetFileName(parent.WslWorkingFolder.TrimEnd('/')); + string name = string.IsNullOrEmpty(leaf) ? "bash" : $"{leaf} (bash)"; + + var session = _sessionManager.CreateSession(name, parent.WorkingFolder, "bash", "", parent.GroupId); + session.Kind = Models.SessionKind.Wsl; + session.WslDistro = parent.WslDistro; + session.WslUser = parent.WslUser; + session.WslWorkingFolder = parent.WslWorkingFolder; + _ = LaunchSessionAsync(session); + } + private static bool ExistsOnPath(string executable) { try From 96003de4cdef90f44de099a1917c8860b2094bd2 Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 13:43:53 +0200 Subject: [PATCH 09/24] fix(worktree): new worktree sessions inherit kind from the parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A WSL session whose user picked "New worktree from this branch" got a fresh session at the UNC path of the new worktree — but as a Local kind running PowerShell, so the parent's command (claude, codex, etc.) failed with "not recognized" inside a PS prompt at \\wsl$\\.... Same fix applies to "New session in sibling worktree" and the sibling- worktree checkbox in the New Session dialog: all three created child sessions without copying Kind/SSH/WSL fields, so a WSL parent quietly demoted its children to Local. Centralized in InheritSessionKindFrom, which also derives the child's WslWorkingFolder from its WorkingFolder UNC (the worktree path the caller built). DuplicateSessionAsync refactored to use the same helper — its inline copy logic was about to drift. --- src/CodeShellManager/MainWindow.xaml.cs | 67 +++++++++++++++++++------ 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 5561a8f..602cee6 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -582,6 +582,7 @@ private async Task LaunchAndFollowUpWorktreesAsync(ShellSession primary, IReadOn string.IsNullOrEmpty(primary.GroupId) ? null : primary.GroupId, colorOverride: null, afterSessionId: anchorId); + InheritSessionKindFrom(sibling, primary); // Inherit profile so siblings look identical. sibling.ProfileFontFamily = primary.ProfileFontFamily; sibling.ProfileFontSize = primary.ProfileFontSize; @@ -616,20 +617,7 @@ private async Task DuplicateSessionAsync(SessionViewModel parent) string.IsNullOrEmpty(p.GroupId) ? null : p.GroupId, colorOverride: null, afterSessionId: parent.Id); - clone.Kind = p.Kind; - if (p.Kind == Models.SessionKind.Ssh) - { - clone.SshUser = p.SshUser; - clone.SshHost = p.SshHost; - clone.SshPort = p.SshPort; - clone.SshRemoteFolder = p.SshRemoteFolder; - } - else if (p.Kind == Models.SessionKind.Wsl) - { - clone.WslDistro = p.WslDistro; - clone.WslUser = p.WslUser; - clone.WslWorkingFolder = p.WslWorkingFolder; - } + InheritSessionKindFrom(clone, p); clone.ProfileFontFamily = p.ProfileFontFamily; clone.ProfileFontSize = p.ProfileFontSize; clone.ProfileFontWeight = p.ProfileFontWeight; @@ -674,6 +662,55 @@ private string DeriveDuplicateName(string baseName) return $"{stem} ({start})"; } + /// + /// Propagates a parent session's and kind-specific + /// fields (SSH host/user/port, WSL distro/user) onto a freshly-created child + /// session. For WSL children it also derives WslWorkingFolder from the + /// child's WorkingFolder, which the worktree code paths set to a + /// \\wsl$\<distro>\… UNC. Without this step a new session spawned + /// from a WSL parent (Duplicate, sibling worktree, new worktree) silently falls + /// back to and tries to run the parent's + /// command (e.g. claude) inside a Windows PowerShell at the UNC path. + /// + private static void InheritSessionKindFrom(Models.ShellSession target, Models.ShellSession source) + { + target.Kind = source.Kind; + if (source.Kind == Models.SessionKind.Ssh) + { + target.SshUser = source.SshUser; + target.SshHost = source.SshHost; + target.SshPort = source.SshPort; + target.SshRemoteFolder = source.SshRemoteFolder; + return; + } + if (source.Kind == Models.SessionKind.Wsl) + { + target.WslDistro = source.WslDistro; + target.WslUser = source.WslUser; + + var (parsedDistro, parsedLinux) = Services.GitService.TryParseWslUnc(target.WorkingFolder); + if (!string.IsNullOrEmpty(parsedDistro)) + { + // Common path: WorkingFolder is a WSL UNC the caller already built. + target.WslWorkingFolder = parsedLinux == "/" ? "" : parsedLinux; + } + else if (!string.IsNullOrEmpty(target.WorkingFolder) && target.WorkingFolder.StartsWith('/')) + { + // Caller passed a Linux path directly (e.g. typed into a worktree dialog). + target.WslWorkingFolder = target.WorkingFolder; + target.WorkingFolder = Services.WslDiscoveryService.ToUncPath( + source.WslDistro, target.WslWorkingFolder); + } + else + { + // Unknown shape — keep the parent's folder so the child at least lands + // somewhere usable instead of in $HOME-by-accident. + target.WslWorkingFolder = source.WslWorkingFolder; + target.WorkingFolder = source.WorkingFolder; + } + } + } + /// /// Launches a new session in an existing sibling worktree (path resolved via /// `git worktree list`). Inherits the source session's command, group, and profile. @@ -695,6 +732,7 @@ private async Task LaunchSessionInSiblingWorktreeAsync(SessionViewModel parent, string.IsNullOrEmpty(p.GroupId) ? null : p.GroupId, colorOverride: null, afterSessionId: parent.Id); + InheritSessionKindFrom(sibling, p); sibling.ProfileFontFamily = p.ProfileFontFamily; sibling.ProfileFontSize = p.ProfileFontSize; sibling.ProfileFontWeight = p.ProfileFontWeight; @@ -2973,6 +3011,7 @@ private async Task OpenNewWorktreeDialogAsync(SessionViewModel source) source.Session.Args, string.IsNullOrEmpty(source.Session.GroupId) ? null : source.Session.GroupId, source.Session.ColorOverride); + InheritSessionKindFrom(newSession, source.Session); newSession.ProfileFontFamily = source.Session.ProfileFontFamily; newSession.ProfileFontSize = source.Session.ProfileFontSize; newSession.ProfileFontWeight = source.Session.ProfileFontWeight; From 86b73ff7334ccf7985ad447e3672d924997fed3b Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 13:57:49 +0200 Subject: [PATCH 10/24] fix(new-session): drop SelectedPath so the WSL picker's Folder field starts clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passing the seed UNC to both InitialDirectory and SelectedPath made the COM file dialog show the raw UNC in the bottom "Folder:" textbox — which Windows then renders as a truncated, slash-flipped tail (e.g. "bu/home/bitblade" for \\wsl$\Ubuntu\home\bitblade). InitialDirectory alone navigates the tree to the right place; SelectedPath was only making the visible textbox look broken. --- src/CodeShellManager/Views/NewSessionDialog.xaml.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index 0bf4a1a..6e8fed1 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -358,16 +358,16 @@ private async void BrowseWslFolder_Click(object sender, RoutedEventArgs e) string selectedDistro = (WslDistroCombo.SelectedItem as ComboBoxItem)?.Tag as string ?? ""; string seed = await ComputeWslBrowseSeedAsync(selectedDistro, WslUserBox.Text.Trim()); - // Both InitialDirectory AND SelectedPath are needed: SelectedPath alone leaves - // the COM file dialog rooted at the user's last location (often Documents) for - // UNC paths it can't resolve to a shell namespace folder. Setting both makes the - // dialog navigate into the WSL share. + // Only InitialDirectory is set: it navigates the dialog to the seed but + // leaves the bottom "Folder:" textbox empty (the user is about to pick anyway). + // Setting SelectedPath as well shoves the raw UNC into that textbox, which the + // shell renders as a truncated, slash-flipped mess (e.g. "bu/home/bitblade") — + // worse than empty. using var dialog = new System.Windows.Forms.FolderBrowserDialog { Description = "Select Linux working folder (inside WSL)", UseDescriptionForTitle = true, InitialDirectory = seed, - SelectedPath = seed, }; if (dialog.ShowDialog() != System.Windows.Forms.DialogResult.OK) return; From bf8011f99372b7283907e11f468a8facf5693bd9 Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 15:18:55 +0200 Subject: [PATCH 11/24] fix(wsl): drain stderr in GetDistroHomeAsync to avoid pipe-buffer deadlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetDistrosAsync correctly drains both streams; GetDistroHomeAsync didn't. If wsl.exe writes enough to stderr (transient init notices, a stopped distro error) the child would block on its stderr buffer and the stdout await would never complete — silently falling through to the 3s timeout on every Browse click for WSL sessions. Per Copilot review. --- src/CodeShellManager/Services/WslDiscoveryService.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/CodeShellManager/Services/WslDiscoveryService.cs b/src/CodeShellManager/Services/WslDiscoveryService.cs index 4d2a564..b038df7 100644 --- a/src/CodeShellManager/Services/WslDiscoveryService.cs +++ b/src/CodeShellManager/Services/WslDiscoveryService.cs @@ -151,9 +151,15 @@ internal static IReadOnlyList Parse(string raw) using var process = Process.Start(psi); if (process is null) return null; + // Drain BOTH stdout and stderr. If we only awaited stdout, a chatty + // wsl.exe error (e.g. distro stopped, transient init message) could + // fill the stderr pipe buffer and block the child — the stdout await + // would never complete and we'd silently fall through to the timeout. var outTask = process.StandardOutput.ReadToEndAsync(); - var completed = await Task.WhenAny(outTask, Task.Delay(3000)); - if (completed != outTask) { try { process.Kill(); } catch { } return null; } + var errTask = process.StandardError.ReadToEndAsync(); + var bothTask = Task.WhenAll(outTask, errTask); + var completed = await Task.WhenAny(bothTask, Task.Delay(3000)); + if (completed != bothTask) { try { process.Kill(); } catch { } return null; } try { await process.WaitForExitAsync(); } catch { } if (process.ExitCode != 0) return null; From 6c93b6720d564796be1338b80b893654f74ee022 Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 15:18:59 +0200 Subject: [PATCH 12/24] docs(session-vm): update stale comment on WSL git refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed Git for Windows handles WSL UNCs natively — but the preceding GitService routing through wsl.exe exists precisely because it doesn't. Comment now reflects the actual dispatch. Per Copilot review. --- src/CodeShellManager/ViewModels/SessionViewModel.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index e5467f4..d04f8e2 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -71,8 +71,9 @@ public SessionViewModel(ShellSession session) public async Task RefreshGitInfoAsync() { // SSH sessions have no local working folder to inspect. WSL sessions store - // their WorkingFolder as a `\\wsl$\\...` UNC path, which Git for - // Windows handles via `git -C` — so the Local code path applies unchanged. + // their WorkingFolder as a `\\wsl$\\...` UNC; GitService detects that + // and dispatches to `wsl.exe -- git -C ` internally (Git for + // Windows itself trips on those UNCs — dubious-ownership / .git symlinks). if (Session.Kind == SessionKind.Ssh) return; var (branch, isDirty) = await GitService.GetGitInfoAsync(Session.WorkingFolder); GitBranch = branch; From b26accd2d338f905c8f7beadad3de6d61babd789 Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 15:19:38 +0200 Subject: [PATCH 13/24] feat(run-commands): allow template seeding for WSL sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous guard "Kind != Local → return" was too conservative — RunCommandTemplatesService.SeedFor calls Directory.EnumerateFiles, which works fine on `\\wsl$\\…` UNCs, and RunInstance already runs WSL sessions' commands via `wsl.exe -- bash -lc`. So a WSL project with a package.json or Cargo.toml at its root now gets its templates the same way a Local one does. Only SSH stays opted out. Per Copilot review. --- src/CodeShellManager/MainWindow.xaml.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 602cee6..edea971 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -755,9 +755,12 @@ private async Task LaunchSessionInSiblingWorktreeAsync(SessionViewModel parent, /// private void SeedRunCommandsAsync(Models.ShellSession session) { - // Templates are local-only — SSH and WSL working folders are out of reach for - // the synchronous Directory.EnumerateFiles probe in RunCommandTemplatesService. - if (session.Kind != Models.SessionKind.Local) return; + // SSH is out of reach for the synchronous Directory.EnumerateFiles probe. + // WSL is reachable via the `\\wsl$\\…` UNC view — slow on first + // access if the distro VM is stopped, but the probe runs on a background + // task so the UI doesn't block. RunInstance already wraps run commands in + // `wsl.exe -- bash -lc` for WSL parents. + if (session.Kind == Models.SessionKind.Ssh) return; if (session.RunCommands.Count > 0) return; if (string.IsNullOrWhiteSpace(session.WorkingFolder)) return; From ef4c73e8c6e1f4daa83c720961a2720282ce2a2d Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 15:19:56 +0200 Subject: [PATCH 14/24] fix(recents): persist Kind + WSL fields so reopened WSL sessions stay WSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecentlyClosedEntry only captured IsRemote + SSH fields. Closing a WSL session and reopening it via Ctrl+Shift+T (or the Recently Closed list in the New Session dialog) resurrected it as Local at the `\\wsl$\…` UNC path — same failure mode Copilot called out for the worktree path, just on a different code route. - Add Kind / WslDistro / WslUser / WslWorkingFolder to the entry record; FromSession copies them. - Mirror the IsRemote→Ssh migration shim on RecentlyClosedEntry too, so state.json files written before this commit (no Kind key) still render the right subtitle and reopen as SSH. - ReopenClosedSessionAsync copies Kind first (so a WSL entry doesn't get demoted by the IsRemote shim) and the WSL fields second. - Subtitle now keys on Kind so a WSL entry shows `: `. Per Copilot review. --- src/CodeShellManager/MainWindow.xaml.cs | 10 +++++- .../Models/RecentlyClosedEntry.cs | 36 ++++++++++++++++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index edea971..449e90a 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -507,11 +507,19 @@ private async Task ReopenClosedSessionAsync(RecentlyClosedEntry en string.IsNullOrEmpty(entry.GroupId) ? null : entry.GroupId, colorOverride: entry.ColorOverride); - session.IsRemote = entry.IsRemote; + // Kind first so the IsRemote shim below doesn't promote a Wsl entry back + // to Ssh when its IsRemote happens to round-trip as false. + session.Kind = entry.Kind; + // Legacy entries (pre-Kind) have Kind=Local but IsRemote=true for SSH — + // the IsRemote setter on ShellSession migrates that to Kind=Ssh. + if (entry.Kind == Models.SessionKind.Local) session.IsRemote = entry.IsRemote; session.SshUser = entry.SshUser; session.SshHost = entry.SshHost; session.SshPort = entry.SshPort; session.SshRemoteFolder = entry.SshRemoteFolder; + session.WslDistro = entry.WslDistro; + session.WslUser = entry.WslUser; + session.WslWorkingFolder = entry.WslWorkingFolder; session.ProfileFontFamily = entry.ProfileFontFamily; session.ProfileFontSize = entry.ProfileFontSize; diff --git a/src/CodeShellManager/Models/RecentlyClosedEntry.cs b/src/CodeShellManager/Models/RecentlyClosedEntry.cs index ddff9b6..18489e3 100644 --- a/src/CodeShellManager/Models/RecentlyClosedEntry.cs +++ b/src/CodeShellManager/Models/RecentlyClosedEntry.cs @@ -23,12 +23,29 @@ public class RecentlyClosedEntry public string GroupId { get; set; } = ""; public string? ColorOverride { get; set; } - public bool IsRemote { get; set; } + /// + /// Kind of the closed session — needed so a reopened WSL session comes back + /// as WSL instead of falling back to Local at the UNC path. Mirrors the + /// migration: setting + /// to true promotes Local → Ssh, so legacy state.json entries (which + /// only carried IsRemote) still display the right subtitle and reopen as SSH. + /// + public SessionKind Kind { get; set; } = SessionKind.Local; + + public bool IsRemote + { + get => Kind == SessionKind.Ssh; + set { if (value && Kind == SessionKind.Local) Kind = SessionKind.Ssh; } + } public string SshUser { get; set; } = ""; public string SshHost { get; set; } = ""; public int SshPort { get; set; } = 22; public string SshRemoteFolder { get; set; } = ""; + public string WslDistro { get; set; } = ""; + public string WslUser { get; set; } = ""; + public string WslWorkingFolder { get; set; } = ""; + public string? ProfileFontFamily { get; set; } public int? ProfileFontSize { get; set; } public string? ProfileFontWeight { get; set; } @@ -57,11 +74,15 @@ public class RecentlyClosedEntry Args = s.Args, GroupId = s.GroupId, ColorOverride = s.ColorOverride, + Kind = s.Kind, IsRemote = s.IsRemote, SshUser = s.SshUser, SshHost = s.SshHost, SshPort = s.SshPort, SshRemoteFolder = s.SshRemoteFolder, + WslDistro = s.WslDistro, + WslUser = s.WslUser, + WslWorkingFolder = s.WslWorkingFolder, ProfileFontFamily = s.ProfileFontFamily, ProfileFontSize = s.ProfileFontSize, ProfileFontWeight = s.ProfileFontWeight, @@ -84,8 +105,13 @@ public class RecentlyClosedEntry ClosedAt = DateTime.UtcNow, }; - /// Friendly subtitle for the recents UI — folder or user@host. - public string Subtitle => IsRemote - ? (string.IsNullOrWhiteSpace(SshUser) ? SshHost : $"{SshUser}@{SshHost}") - : WorkingFolder; + /// Friendly subtitle for the recents UI — kind-specific locator. + public string Subtitle => Kind switch + { + SessionKind.Ssh => string.IsNullOrWhiteSpace(SshUser) ? SshHost : $"{SshUser}@{SshHost}", + SessionKind.Wsl => string.IsNullOrEmpty(WslWorkingFolder) + ? WslDistro + : $"{WslDistro}: {WslWorkingFolder}", + _ => WorkingFolder, + }; } From ae34fbb3c361ceabed8f3b7aba51a77e79f7fe89 Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 15:20:29 +0200 Subject: [PATCH 15/24] fix(git): TranslateUncArgsToLinux handles quoted UNC paths with spaces The unquoted regex stops at whitespace, so a quoted UNC like "\\wsl\$\Ubuntu\home\alice\my repo" (the shape `worktree add ` would produce for a Linux path with a space) used to be half-translated and yield a broken git command. Add a two-pass approach: first replace quoted runs (consuming the content up to the closing quote and re-quoting the Linux output), then fall back to the existing unquoted pass for arguments that never needed quoting in the first place. Per Copilot review. --- src/CodeShellManager/Services/GitService.cs | 25 ++++++++++++++----- .../GitServiceWslRoutingTests.cs | 19 ++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/CodeShellManager/Services/GitService.cs b/src/CodeShellManager/Services/GitService.cs index a64ed02..b87ba01 100644 --- a/src/CodeShellManager/Services/GitService.cs +++ b/src/CodeShellManager/Services/GitService.cs @@ -278,14 +278,27 @@ internal static (string? distro, string linuxPath) TryParseWslUnc(string path) internal static string TranslateUncArgsToLinux(string arguments, string distro) { if (string.IsNullOrEmpty(arguments)) return arguments; - // Match \\wsl$\\ or \\wsl.localhost\\; \ is - // greedy up to the next quote/space (anything that would terminate a shell token). - var pattern = $@"\\\\wsl(?:\$|\.localhost)\\{Regex.Escape(distro)}(\\[^""\s]*)?"; - return Regex.Replace(arguments, pattern, m => + string esc = Regex.Escape(distro); + string body = $@"\\\\wsl(?:\$|\.localhost)\\{esc}"; + + // Pass 1: quoted UNCs ("\\wsl$\\..."). The tail may contain spaces + // and runs until the closing quote — without this pass, the unquoted regex + // below would stop at the first space and produce a half-translated path. + arguments = Regex.Replace(arguments, $@"""({body}(?:\\[^""]*)?)""", m => { - string tail = m.Groups[1].Value; - return string.IsNullOrEmpty(tail) ? "/" : tail.Replace('\\', '/'); + var (_, linux) = TryParseWslUnc(m.Groups[1].Value); + return "\"" + (string.IsNullOrEmpty(linux) ? "/" : linux) + "\""; }, RegexOptions.IgnoreCase); + + // Pass 2: unquoted UNCs. The tail runs to whitespace; if a path needed + // spaces it would have been quoted and handled above. + arguments = Regex.Replace(arguments, $@"{body}(?:\\[^""\s]*)?", m => + { + var (_, linux) = TryParseWslUnc(m.Value); + return string.IsNullOrEmpty(linux) ? "/" : linux; + }, RegexOptions.IgnoreCase); + + return arguments; } /// diff --git a/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs b/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs index 3281996..714c36f 100644 --- a/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs +++ b/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs @@ -85,4 +85,23 @@ public void TranslateLinuxPathsToUnc_StatusPorcelain_Untouched() string raw = "M README.md\n?? new.txt\n"; Assert.Equal(raw, GitService.TranslateLinuxPathsToUnc(raw, "Ubuntu")); } + + [Fact] + public void TranslateUncArgsToLinux_QuotedUncWithSpaces_TranslatedWholeAndReQuoted() + { + // Regression: the unquoted regex stops at whitespace, so a quoted UNC + // containing a space (worktree add target) used to be half-translated. + string args = "worktree add \"\\\\wsl$\\Ubuntu\\home\\alice\\my repo\" main"; + string translated = GitService.TranslateUncArgsToLinux(args, "Ubuntu"); + Assert.Contains("\"/home/alice/my repo\"", translated); + Assert.DoesNotContain(@"\\wsl$\Ubuntu", translated); + } + + [Fact] + public void TranslateUncArgsToLinux_QuotedUncRoot_BecomesQuotedRoot() + { + string args = "rev-parse \"\\\\wsl$\\Ubuntu\""; + string translated = GitService.TranslateUncArgsToLinux(args, "Ubuntu"); + Assert.Equal("rev-parse \"/\"", translated); + } } From 9c2ac28c31742d180c938979d82cfbe284796e5d Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 15:20:45 +0200 Subject: [PATCH 16/24] fix(args): quote distro/user/cwd values in WSL arg builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShellSession.BuildWslArgs, RunInstance.BuildWslArgs, and GitService's RunGitInWslAsync concatenated WslDistro/WslUser/WslWorkingFolder straight into a single argument string. Most distro names are space-free in practice, but Linux working folders genuinely can have spaces (`/home/alice/my proj`) and `wsl --cd` then sees two arguments. Add a conservative QuoteForCmd helper on ShellSession (internal, so tests reach it via InternalsVisibleTo): leaves space-free values alone so existing call sites and tests don't churn, and double-quotes anything that needs it (with embedded `"` escaped as `\"`). Not migrating to ProcessStartInfo.ArgumentList — PseudoTerminal's API takes a single command-line string, and reshaping it is out of scope for this PR. The quoting helper is the surgical fix. Per Copilot review. --- src/CodeShellManager/Models/ShellSession.cs | 23 +++++++++++++++---- src/CodeShellManager/Services/GitService.cs | 6 ++--- src/CodeShellManager/Services/RunInstance.cs | 7 +++--- .../ShellSessionTests.cs | 23 +++++++++++++++++++ 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/CodeShellManager/Models/ShellSession.cs b/src/CodeShellManager/Models/ShellSession.cs index 30882a1..1d4da2b 100644 --- a/src/CodeShellManager/Models/ShellSession.cs +++ b/src/CodeShellManager/Models/ShellSession.cs @@ -140,24 +140,39 @@ internal string BuildSshArgs() /// Builds the argument string passed to wsl.exe. /// Example: "-d Ubuntu -u alice --cd /home/alice/project -- bash -lc \"claude\"" /// The command is wrapped in bash -lc so PATH-resolved tools (nvm-managed - /// node, pyenv, etc.) work the same as in a user-launched login shell. + /// node, pyenv, etc.) work the same as in a user-launched login shell. Distro, + /// user, and working-folder values are passed through + /// so values containing spaces (Linux paths often do) survive Win32 arg parsing. /// internal string BuildWslArgs() { if (string.IsNullOrWhiteSpace(WslDistro)) throw new InvalidOperationException("WslDistro must be set for WSL sessions."); var sb = new StringBuilder(); - sb.Append($"-d {WslDistro}"); + sb.Append($"-d {QuoteForCmd(WslDistro)}"); if (!string.IsNullOrWhiteSpace(WslUser)) - sb.Append($" -u {WslUser}"); + sb.Append($" -u {QuoteForCmd(WslUser)}"); if (!string.IsNullOrWhiteSpace(WslWorkingFolder)) - sb.Append($" --cd {WslWorkingFolder}"); + sb.Append($" --cd {QuoteForCmd(WslWorkingFolder)}"); var shell = string.IsNullOrWhiteSpace(Command) ? "bash" : Command; string inner = string.IsNullOrWhiteSpace(Args) ? shell : $"{shell} {Args}"; sb.Append($" -- bash -lc \"{inner.Replace("\"", "\\\"")}\""); return sb.ToString(); } + /// + /// Conservative Win32 command-line quoting: leaves space-free, quote-free values + /// alone (so existing call sites and tests don't regress) and wraps anything else + /// in double quotes with embedded " escaped as \". Used by the WSL + /// arg builders (here and in RunInstance) and GitService's wsl.exe routing. + /// + internal static string QuoteForCmd(string value) + { + if (string.IsNullOrEmpty(value)) return "\"\""; + if (value.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) return value; + return "\"" + value.Replace("\"", "\\\"") + "\""; + } + // ── Display helpers (single source of truth — see MainWindow sidebar / VM) ──── /// diff --git a/src/CodeShellManager/Services/GitService.cs b/src/CodeShellManager/Services/GitService.cs index b87ba01..1072ac8 100644 --- a/src/CodeShellManager/Services/GitService.cs +++ b/src/CodeShellManager/Services/GitService.cs @@ -214,10 +214,10 @@ public static async Task> ListBranchesAsync(string folderP string distro, string linuxPath, string arguments, int timeoutMs) { string translatedArgs = TranslateUncArgsToLinux(arguments, distro); - // Use double quotes around the cwd — wsl.exe + Linux git both accept them and - // it sidesteps the apostrophe-in-path footgun that single quotes would have. + // QuoteForCmd handles spaces in both the distro name (rare) and the cwd + // (Linux paths often have them) without disturbing the simple-name case. string cwd = string.IsNullOrEmpty(linuxPath) ? "/" : linuxPath; - string args = $"-d {distro} -- git -C \"{cwd}\" {translatedArgs}"; + string args = $"-d {Models.ShellSession.QuoteForCmd(distro)} -- git -C {Models.ShellSession.QuoteForCmd(cwd)} {translatedArgs}"; var psi = new ProcessStartInfo("wsl.exe") { diff --git a/src/CodeShellManager/Services/RunInstance.cs b/src/CodeShellManager/Services/RunInstance.cs index 7c21e40..86118e2 100644 --- a/src/CodeShellManager/Services/RunInstance.cs +++ b/src/CodeShellManager/Services/RunInstance.cs @@ -277,10 +277,11 @@ internal static string BuildSshArgs(ShellSession parent, string commandLine) internal static string BuildWslArgs(ShellSession parent, string commandLine) { var sb = new StringBuilder(); - sb.Append($"-d {parent.WslDistro}"); - if (!string.IsNullOrWhiteSpace(parent.WslUser)) sb.Append($" -u {parent.WslUser}"); + sb.Append($"-d {ShellSession.QuoteForCmd(parent.WslDistro)}"); + if (!string.IsNullOrWhiteSpace(parent.WslUser)) + sb.Append($" -u {ShellSession.QuoteForCmd(parent.WslUser)}"); if (!string.IsNullOrWhiteSpace(parent.WslWorkingFolder)) - sb.Append($" --cd {parent.WslWorkingFolder}"); + sb.Append($" --cd {ShellSession.QuoteForCmd(parent.WslWorkingFolder)}"); sb.Append(" -- bash -lc "); sb.Append(SingleQuoteEscape(commandLine)); return sb.ToString(); diff --git a/tests/CodeShellManager.Tests/ShellSessionTests.cs b/tests/CodeShellManager.Tests/ShellSessionTests.cs index 13c6dc9..39bb35c 100644 --- a/tests/CodeShellManager.Tests/ShellSessionTests.cs +++ b/tests/CodeShellManager.Tests/ShellSessionTests.cs @@ -190,4 +190,27 @@ public void AccentKey_Wsl_DistinctFromLocal() var local = new ShellSession { WorkingFolder = "/proj" }; Assert.NotEqual(wsl.AccentKey, local.AccentKey); } + + [Theory] + [InlineData("Ubuntu", "Ubuntu")] + [InlineData("", "\"\"")] + [InlineData("/home/alice/proj", "/home/alice/proj")] + [InlineData("/home/alice/my proj", "\"/home/alice/my proj\"")] + [InlineData("with\"quote", "\"with\\\"quote\"")] + public void QuoteForCmd_QuotesWhenNeeded(string input, string expected) + { + Assert.Equal(expected, ShellSession.QuoteForCmd(input)); + } + + [Fact] + public void BuildWslArgs_LinuxPathWithSpaces_QuotesCdValue() + { + var s = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "Ubuntu", + WslWorkingFolder = "/home/alice/my proj", Command = "claude", + }; + Assert.Equal("-d Ubuntu --cd \"/home/alice/my proj\" -- bash -lc \"claude\"", + s.BuildWslArgs()); + } } From 201d61e2da31ee61bf5bb7a661d0c3aa79e543a5 Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 15:21:21 +0200 Subject: [PATCH 17/24] fix(new-session): reject non-WSL paths from the WSL folder picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the user navigated out of `\\wsl$\` (e.g. into `C:\Users\…`) and picked there, we silently stuffed the Windows path into WslWorkingFolderBox — `wsl.exe --cd C:\…` then failed at session start with a confusing message. Show a clear MessageBox naming the picked path and explaining which folders are valid, and leave the textbox unchanged so the user can try again. Per Copilot review. --- src/CodeShellManager/Views/NewSessionDialog.xaml.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index 6e8fed1..b8982c0 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -374,9 +374,14 @@ private async void BrowseWslFolder_Click(object sender, RoutedEventArgs e) var (distro, linuxPath) = ParseWslUncPath(dialog.SelectedPath); if (string.IsNullOrEmpty(distro)) { - // User picked something outside `\\wsl$\\` — fall back to just - // setting the raw path so we don't silently throw away their selection. - WslWorkingFolderBox.Text = dialog.SelectedPath; + // User navigated out of the WSL share entirely (e.g. into C:\…). Putting + // a Windows path into the Linux-folder box would just make `wsl --cd` + // fail later — so refuse the selection and tell them why. + System.Windows.MessageBox.Show( + $"'{dialog.SelectedPath}' is not inside a WSL distro.\n\n" + + "Please pick a folder under one of the distros shown in the left pane (Linux → Ubuntu, etc.).", + "Not a WSL folder", MessageBoxButton.OK, MessageBoxImage.Warning); + return; } else { From dfc0293f47dde3f2eec3ffe90603913ab55a5616 Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 15:21:25 +0200 Subject: [PATCH 18/24] fix(new-session): resolve \$HOME eagerly when WslWorkingFolder is blank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the user picked a WSL distro but left the Linux Working Folder empty, the launcher omitted `--cd` (so the shell correctly landed in \$HOME) but ToUncPath produced `\\wsl\$\` — the distro root. GitService then keyed on that UNC, asked git for status at "/", and came back empty even when \$HOME was a real repo. Sidebar branch info silently disappeared. Make Start_Click async and call GetDistroHomeAsync (the cached lookup we already use for the Browse picker) when WslWorkingFolder is empty, so the session's WorkingFolder UNC and its Linux path stay aligned. Best-effort: if WSL is unreachable, fall through to the existing behavior (land in \$HOME, no git info). Per Copilot review. --- .../Views/NewSessionDialog.xaml.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index b8982c0..979b638 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -503,7 +503,7 @@ private void ProfileCombo_SelectionChanged(object sender, SelectionChangedEventA ProfileColorSchemeJson = profile.ColorSchemeJson; } - private void Start_Click(object sender, RoutedEventArgs e) + private async void Start_Click(object sender, RoutedEventArgs e) { IsRemote = IsRemoteMode; IsWsl = IsWslMode; @@ -534,6 +534,19 @@ private void Start_Click(object sender, RoutedEventArgs e) WslUser = WslUserBox.Text.Trim(); WslWorkingFolder = WslWorkingFolderBox.Text.Trim(); + // If the user left the Linux folder blank, resolve $HOME eagerly so the + // session's WorkingFolder UNC and its Linux path stay in sync. Otherwise + // git status runs against the distro root (\\wsl$\ → "/") while + // the shell actually starts in $HOME — and the sidebar branch info goes + // missing for repos under home. Best-effort: silent fallback to blank + // (the existing "land in $HOME, no git info" behavior) when WSL is + // unreachable. + if (string.IsNullOrEmpty(WslWorkingFolder)) + { + string? home = await WslDiscoveryService.GetDistroHomeAsync(WslDistro, WslUser); + if (!string.IsNullOrEmpty(home)) WslWorkingFolder = home; + } + var selectedTag = (CommandCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "bash"; string raw = selectedTag == "custom" ? CustomArgsBox.Text.Trim() : selectedTag; var (exe, args) = CommandLineSplitter.Split(raw); From 73694d039116d7068fb13e1493673a615048f4cc Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 15:37:56 +0200 Subject: [PATCH 19/24] fix(wsl): honor "Never throws" contract on discovery helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetDistrosAsync and GetDistroHomeAsync caught Win32Exception and FileNotFoundException only, but Process.Start can throw InvalidOperationException / PlatformNotSupportedException and the read pipeline can throw IOException — so the doc claim "Never throws — every failure mode collapses to an empty list" wasn't quite accurate. A stray exception here crashes the New Session dialog's Loaded handler, which is a worse outcome than "no WSL distros listed." Broaden the outer catch to Exception and document why. Per Copilot review (round 2). --- .../Services/WslDiscoveryService.cs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/CodeShellManager/Services/WslDiscoveryService.cs b/src/CodeShellManager/Services/WslDiscoveryService.cs index b038df7..58f699c 100644 --- a/src/CodeShellManager/Services/WslDiscoveryService.cs +++ b/src/CodeShellManager/Services/WslDiscoveryService.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.Diagnostics; -using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -67,13 +65,14 @@ public static async Task> GetDistrosAsync() return Parse(outTask.Result); } - catch (Win32Exception) - { - // wsl.exe not on PATH — WSL feature isn't installed. - return Array.Empty(); - } - catch (FileNotFoundException) + catch (Exception) { + // Honor the "Never throws" contract: every failure mode (wsl.exe absent, + // I/O hiccup, transient process error) collapses to an empty list so the + // dialog's Loaded handler never crashes the picker. Specific causes were + // previously caught individually (Win32Exception for missing wsl.exe, + // FileNotFoundException) but Process.Start + the read pipeline can throw + // a wider set than that. return Array.Empty(); } } @@ -168,8 +167,7 @@ internal static IReadOnlyList Parse(string raw) lock (_homeCache) _homeCache[key] = home; return home; } - catch (Win32Exception) { return null; } - catch (FileNotFoundException) { return null; } + catch (Exception) { return null; } } private static readonly Dictionary _homeCache = new(); From 08140e32ee219281e9d1a40b5174337966bf8987 Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 15:42:47 +0200 Subject: [PATCH 20/24] fix(wsl): Parse handles distro names with spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token splitter took tokens[idx] as the name, so a `wsl --import "My Distro" …` row was tokenized as name="My", state="Distro", version=0 (the actual "Running"/"Stopped" text isn't a digit, so int.TryParse silently failed). The picker would then list a phantom "My" distro and `wsl -d My` would error at session start. Switch to consuming from the trailing end of the line: VERSION is always the last token, STATE the second-to-last, and everything in between (after an optional leading `*` for the default-distro marker) is the name joined by spaces. Per Copilot review (round 2, suppressed comment). --- .../Services/WslDiscoveryService.cs | 21 ++++++++++++------- .../WslDiscoveryServiceTests.cs | 17 +++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/CodeShellManager/Services/WslDiscoveryService.cs b/src/CodeShellManager/Services/WslDiscoveryService.cs index 58f699c..83cf89a 100644 --- a/src/CodeShellManager/Services/WslDiscoveryService.cs +++ b/src/CodeShellManager/Services/WslDiscoveryService.cs @@ -94,16 +94,21 @@ internal static IReadOnlyList Parse(string raw) if (line.TrimStart().StartsWith("NAME", StringComparison.Ordinal)) continue; var tokens = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); - if (tokens.Length < 2) continue; - bool isDefault = tokens[0] == "*"; - int idx = isDefault ? 1 : 0; - if (tokens.Length - idx < 1) continue; + bool isDefault = tokens.Length > 0 && tokens[0] == "*"; + int firstNameIdx = isDefault ? 1 : 0; - string name = tokens[idx]; - string state = tokens.Length - idx >= 2 ? tokens[idx + 1] : ""; - int version = 0; - if (tokens.Length - idx >= 3) int.TryParse(tokens[idx + 2], out version); + // `wsl -l -v` always emits three columns: NAME, STATE, VERSION. NAME can + // contain spaces if the user `wsl --import`'d a distro with one (rare but + // legal), so consume from the end instead of the start: last token is + // VERSION, second-to-last is STATE, anything in between is the name. + if (tokens.Length - firstNameIdx < 3) continue; + + int versionIdx = tokens.Length - 1; + int stateIdx = tokens.Length - 2; + string name = string.Join(' ', tokens, firstNameIdx, stateIdx - firstNameIdx); + string state = tokens[stateIdx]; + int.TryParse(tokens[versionIdx], out int version); results.Add(new WslDistro(name, version, isDefault, state)); } diff --git a/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs b/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs index e2e54eb..7421917 100644 --- a/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs +++ b/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs @@ -82,4 +82,21 @@ public void ToUncPath_NoDistro_ReturnsEmpty() { Assert.Equal("", WslDiscoveryService.ToUncPath("", "/home/x")); } + + [Fact] + public void Parse_DistroNameWithSpace_ParsesNameCorrectly() + { + // `wsl --import "My Distro" ...` produces a row where NAME spans two tokens. + // Old parser took just the first token; the from-the-end approach takes + // the trailing two columns as STATE/VERSION and joins the rest as NAME. + const string raw = + " NAME STATE VERSION\n" + + "* My Distro Running 2\n"; + var result = WslDiscoveryService.Parse(raw); + Assert.Single(result); + Assert.Equal("My Distro", result[0].Name); + Assert.Equal("Running", result[0].State); + Assert.Equal(2, result[0].Version); + Assert.True(result[0].IsDefault); + } } From 564a7738c325ef5abf337dea0f8c48e6b55807b8 Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 15:42:49 +0200 Subject: [PATCH 21/24] fix(wsl): QuoteForCmd the distro/user in GetDistroHomeAsync too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last unquoted wsl.exe-arg interpolation we missed in the earlier sweep. With Parse now accepting space-containing distro names, the launcher side has to be ready to receive one — without quoting, a "My Distro" distro would arrive as `-d My Distro` (two args to wsl.exe) and the home-resolution would silently fail. Per Copilot review (round 2, suppressed comment). --- src/CodeShellManager/Services/WslDiscoveryService.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/CodeShellManager/Services/WslDiscoveryService.cs b/src/CodeShellManager/Services/WslDiscoveryService.cs index 83cf89a..884f283 100644 --- a/src/CodeShellManager/Services/WslDiscoveryService.cs +++ b/src/CodeShellManager/Services/WslDiscoveryService.cs @@ -138,8 +138,12 @@ internal static IReadOnlyList Parse(string raw) try { - string args = $"-d {distro}"; - if (!string.IsNullOrEmpty(normalizedUser)) args += $" -u {normalizedUser}"; + // QuoteForCmd for parity with the WSL arg builders — distro and user are + // usually space-free but Parse now accepts space-containing names, so the + // launcher side must not break on the same input. + string args = $"-d {Models.ShellSession.QuoteForCmd(distro)}"; + if (!string.IsNullOrEmpty(normalizedUser)) + args += $" -u {Models.ShellSession.QuoteForCmd(normalizedUser)}"; args += " -- sh -c \"cd ~ && pwd\""; var psi = new ProcessStartInfo("wsl.exe") From c7ef555209fd5db3bc53ff2e560876c0b0bddbf4 Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 20:11:36 +0200 Subject: [PATCH 22/24] fix(run-commands): switch WSL run-args to Windows-style double quotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunInstance.BuildWslArgs wrapped the command in POSIX single quotes via SingleQuoteEscape. But wsl.exe is started directly by CreateProcess (no outer shell), so Windows command-line tokenization runs first and only treats "..." as grouping. With single quotes, `bash -lc 'cargo test'` reached bash split at the space into the two args `'cargo` and `test'` — bash choked on the unbalanced quote and the run-command failed. Mirror the double-quote shape ShellSession.BuildWslArgs already uses: wrap the commandLine in `"..."` and escape embedded `"` as `\"`. The SSH variant keeps SingleQuoteEscape because the WHOLE bash command there is itself inside outer SSH double quotes — the structure is different, so the same trick would actually break it. Updates the existing tests to expect the double-quote shape and adds coverage for the embedded-double-quote case. Per Copilot review. --- src/CodeShellManager/Services/RunInstance.cs | 12 ++++++++-- .../RunInstanceTests.cs | 23 +++++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/CodeShellManager/Services/RunInstance.cs b/src/CodeShellManager/Services/RunInstance.cs index 86118e2..9204516 100644 --- a/src/CodeShellManager/Services/RunInstance.cs +++ b/src/CodeShellManager/Services/RunInstance.cs @@ -282,8 +282,16 @@ internal static string BuildWslArgs(ShellSession parent, string commandLine) sb.Append($" -u {ShellSession.QuoteForCmd(parent.WslUser)}"); if (!string.IsNullOrWhiteSpace(parent.WslWorkingFolder)) sb.Append($" --cd {ShellSession.QuoteForCmd(parent.WslWorkingFolder)}"); - sb.Append(" -- bash -lc "); - sb.Append(SingleQuoteEscape(commandLine)); + // Use Windows-style double quotes here, NOT POSIX single quotes: wsl.exe is + // launched directly by CreateProcess (no outer shell), so Windows command-line + // tokenization runs first and only respects "..." for grouping. Single quotes + // would leak through literally — `bash -lc 'cargo test'` reaches bash split at + // the space into the two args `'cargo` and `test'`, and bash then chokes on + // the unbalanced quote. ShellSession.BuildWslArgs uses this same double-quote + // shape; we mirror it for parity. + sb.Append(" -- bash -lc \""); + sb.Append(commandLine.Replace("\"", "\\\"")); + sb.Append("\""); return sb.ToString(); } diff --git a/tests/CodeShellManager.Tests/RunInstanceTests.cs b/tests/CodeShellManager.Tests/RunInstanceTests.cs index a1b5a23..d427355 100644 --- a/tests/CodeShellManager.Tests/RunInstanceTests.cs +++ b/tests/CodeShellManager.Tests/RunInstanceTests.cs @@ -89,7 +89,9 @@ public void BuildWslArgs_HappyPath_BuildsExpectedShape() WslWorkingFolder = "/home/alice/proj", }; string args = RunInstance.BuildWslArgs(p, "cargo test"); - Assert.Equal("-d Ubuntu -u alice --cd /home/alice/proj -- bash -lc 'cargo test'", args); + // Double quotes (Windows-side grouping) — single quotes would leak through + // Windows command-line tokenization and reach bash as broken token pieces. + Assert.Equal("-d Ubuntu -u alice --cd /home/alice/proj -- bash -lc \"cargo test\"", args); } [Fact] @@ -97,14 +99,27 @@ public void BuildWslArgs_NoUserOrFolder_OmitsFlags() { var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Debian" }; string args = RunInstance.BuildWslArgs(p, "ls"); - Assert.Equal("-d Debian -- bash -lc 'ls'", args); + Assert.Equal("-d Debian -- bash -lc \"ls\"", args); } [Fact] - public void BuildWslArgs_CommandLineWithApostrophe_IsEscaped() + public void BuildWslArgs_CommandLineWithEmbeddedDoubleQuote_Escapes() { + var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu" }; + string args = RunInstance.BuildWslArgs(p, "echo \"hi\""); + Assert.Contains("bash -lc \"echo \\\"hi\\\"\"", args); + } + + [Fact] + public void BuildWslArgs_CommandLineWithApostrophe_PassesThroughVerbatim() + { + // Apostrophes need no escaping from us — the outer wrapper is "..." so + // Windows tokenization keeps the whole commandLine as one argv entry, and + // bash then sees the apostrophe at face value. (What bash does with an + // unbalanced apostrophe is the caller's problem; we just refuse to mangle + // it during arg-building.) var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu" }; string args = RunInstance.BuildWslArgs(p, "echo it's me"); - Assert.Contains(@"bash -lc 'echo it'\''s me'", args); + Assert.Contains("bash -lc \"echo it's me\"", args); } } From e0044f0495c77044a44803c2b034af4549fa6554 Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 20:11:40 +0200 Subject: [PATCH 23/24] fix(git): TranslateLinuxPathsToUnc allows spaces in path tail Same class of regex-stops-at-whitespace bug Copilot already flagged for TranslateUncArgsToLinux, on the return trip. A worktree path like `/home/alice/My Projects/repo` came back as `\\wsl$\Ubuntu\home\alice\My` with the rest left as forward-slashed garbage attached. All of our current callers (rev-parse --git-common-dir, worktree list --porcelain) emit the path as the full remainder of the line, so widening the tail to `[^\r\n'"<>|]+` (anything but newline / shell-meta) is safe and recovers space-containing paths. Comment documents the caller-coupled contract. Per Copilot review. --- src/CodeShellManager/Services/GitService.cs | 8 +++++++- .../GitServiceWslRoutingTests.cs | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/CodeShellManager/Services/GitService.cs b/src/CodeShellManager/Services/GitService.cs index 1072ac8..39108b5 100644 --- a/src/CodeShellManager/Services/GitService.cs +++ b/src/CodeShellManager/Services/GitService.cs @@ -310,7 +310,13 @@ internal static string TranslateUncArgsToLinux(string arguments, string distro) internal static string TranslateLinuxPathsToUnc(string text, string distro) { if (string.IsNullOrEmpty(text)) return text; - return Regex.Replace(text, @"(^|[\s=:])(/[^\s'""<>|]+)", m => + // Tail used to stop at any whitespace, which mangled paths containing spaces + // (`/home/alice/My Projects/proj` came back as `\\wsl$\Ubuntu\home\alice\My` + // with the rest left as forward-slashed garbage). Our callers (rev-parse, + // worktree list --porcelain) always emit the path as the full remainder of + // the line, so widening the tail to "anything but newline / shell-meta" is + // safe and recovers space-containing paths correctly. + return Regex.Replace(text, @"(^|[\s=:])(/[^\r\n'""<>|]+)", m => { string linuxPath = m.Groups[2].Value; string unc = $@"\\wsl$\{distro}" + linuxPath.Replace('/', '\\'); diff --git a/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs b/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs index 714c36f..b6d9133 100644 --- a/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs +++ b/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs @@ -104,4 +104,15 @@ public void TranslateUncArgsToLinux_QuotedUncRoot_BecomesQuotedRoot() string translated = GitService.TranslateUncArgsToLinux(args, "Ubuntu"); Assert.Equal("rev-parse \"/\"", translated); } + + [Fact] + public void TranslateLinuxPathsToUnc_PathContainsSpaces_TranslatesWholePath() + { + // Regression: the tail used to stop at the first whitespace, so a worktree + // path with a space got half-translated. + string raw = "worktree /home/alice/My Projects/repo\n"; + string translated = GitService.TranslateLinuxPathsToUnc(raw, "Ubuntu"); + Assert.Contains(@"\\wsl$\Ubuntu\home\alice\My Projects\repo", translated); + Assert.DoesNotContain("Projects/repo", translated); // no leftover forward slashes + } } From a376a7a9fa64b79333d8bffc61bbf6956a3f25fe Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 20:11:47 +0200 Subject: [PATCH 24/24] refactor(paths): use Path.GetFileName instead of hand-rolled LeafName Two near-identical manual leaf-extraction loops were doing what System.IO.Path.GetFileName already does: trim trailing separators, return the segment after the last one, handle empty input. Path on Windows recognizes both `/` and `\` so it works fine for the Linux- style paths these call sites deal with. - ShellSession.DefaultDisplayName + BuildWslFolderShort drop the local LeafName helper. - NewSessionDialog.AutoFillName drops its inline copy. No behavior change; the two functions returned identical results for every input these call sites would ever see. --- src/CodeShellManager/Models/ShellSession.cs | 16 ++++++---------- .../Views/NewSessionDialog.xaml.cs | 10 +++------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/CodeShellManager/Models/ShellSession.cs b/src/CodeShellManager/Models/ShellSession.cs index 1d4da2b..f93e5b0 100644 --- a/src/CodeShellManager/Models/ShellSession.cs +++ b/src/CodeShellManager/Models/ShellSession.cs @@ -198,7 +198,7 @@ internal static string QuoteForCmd(string value) ? Command : (string.IsNullOrEmpty(WslWorkingFolder) ? WslDistro - : $"{WslDistro}: {LeafName(WslWorkingFolder)}"), + : $"{WslDistro}: {System.IO.Path.GetFileName(WslWorkingFolder.TrimEnd('/'))}"), _ => System.IO.Path.GetFileName(WorkingFolder.TrimEnd('/', '\\')) ?? Command, }; @@ -217,15 +217,11 @@ internal static string QuoteForCmd(string value) private string BuildWslFolderShort() { if (string.IsNullOrWhiteSpace(WslDistro)) return ""; - string leaf = LeafName(WslWorkingFolder); + // Path.GetFileName understands both separators on Windows and returns "" + // for empty input, so it covers our "WslWorkingFolder might be blank" case. + string leaf = string.IsNullOrWhiteSpace(WslWorkingFolder) + ? "" + : System.IO.Path.GetFileName(WslWorkingFolder.TrimEnd('/')); return string.IsNullOrEmpty(leaf) ? WslDistro : $"{WslDistro}: {leaf}"; } - - private static string LeafName(string linuxPath) - { - if (string.IsNullOrWhiteSpace(linuxPath)) return ""; - string trimmed = linuxPath.TrimEnd('/'); - int slash = trimmed.LastIndexOf('/'); - return slash >= 0 ? trimmed[(slash + 1)..] : trimmed; - } } diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index 979b638..993569a 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -287,13 +287,9 @@ private void AutoFillName() { string distro = (WslDistroCombo.SelectedItem as ComboBoxItem)?.Tag as string ?? ""; string folder = WslWorkingFolderBox.Text.Trim(); - string leaf = ""; - if (!string.IsNullOrEmpty(folder)) - { - string trimmed = folder.TrimEnd('/'); - int slash = trimmed.LastIndexOf('/'); - leaf = slash >= 0 ? trimmed[(slash + 1)..] : trimmed; - } + string leaf = string.IsNullOrEmpty(folder) + ? "" + : Path.GetFileName(folder.TrimEnd('/')); suggested = string.IsNullOrEmpty(leaf) ? distro : (string.IsNullOrEmpty(distro) ? leaf : $"{distro}: {leaf}");