From 2b2d7ad0961a1fbdf831fad15de4547cb3f70b7c Mon Sep 17 00:00:00 2001 From: Mark Laagland Date: Sun, 17 May 2026 12:31:04 +0200 Subject: [PATCH 01/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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}"); From 79028c1bd4bc6718990272702c79ea915f9c5976 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 22:06:27 +0200 Subject: [PATCH 25/45] docs(plan): WSL sessions hardening plan for landing PR #65 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- .../2026-09-06-wsl-sessions-hardening.md | 1465 +++++++++++++++++ 1 file changed, 1465 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-06-wsl-sessions-hardening.md diff --git a/docs/superpowers/plans/2026-09-06-wsl-sessions-hardening.md b/docs/superpowers/plans/2026-09-06-wsl-sessions-hardening.md new file mode 100644 index 0000000..f0a4814 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-wsl-sessions-hardening.md @@ -0,0 +1,1465 @@ +# WSL Sessions Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land PR #65 (first-class WSL sessions) on top of current `main`, with every confirmed review finding fixed, the "Edit session" feature taught about WSL, and the legacy `IsRemote` migration moved out of the property setter into the state loader. + +**Architecture:** `ShellSession.Kind` (`Local | Ssh | Wsl`) is the single authority. `IsRemote` becomes a `[JsonIgnore]`d convenience over `Kind`; a JSON-only `LegacyIsRemote` field is read from old `state.json` files and folded into `Kind` by `StateService.Normalize` (the same loader hook that already backfills null collections). Edit/duplicate/reopen flows carry `Kind` plus the three WSL fields through `SessionConfigDraft`, `RecentlyClosedEntry`, and a new `snapshot_json` column in `session_history`. WSL command lines go through one MSVCRT-correct quoting helper. + +**Tech Stack:** .NET 10 / WPF, System.Text.Json, Microsoft.Data.Sqlite, xunit 2.9. + +**Spec:** the review findings recorded in the PR #65 review (this session, 2026-09-06) and the merge decisions in the conversation. Summarised in "Global Constraints" below. + +## Global Constraints + +- Branch: `feat/wsl-sessions-v2` in worktree `C:\Github\umage\CodeShellManager\.claude\worktrees\agent-a9ddd8b8`. Base commit `d4e6fd4` = PR #65 head merged with `origin/main` (658cf00). Never `git stash`; never touch other worktrees. +- Build: `dotnet build src/CodeShellManager/CodeShellManager.csproj -nologo -v q` must print `Build succeeded` with **no new warnings**. +- Tests: `dotnet test tests/CodeShellManager.Tests/ -nologo -v q` must pass with **zero failures** after every task (baseline 400 passing). +- Colours: Catppuccin Mocha hex literals only (see CLAUDE.md "Color / Theme"). +- All `state.json` and import-file content is **untrusted** (CLAUDE.md, `RunInstance.IsLaunchableUrl` precedent). Nothing read from it may throw on the launch or save path, and everything that reaches a command line must be quoted by the shared helper. +- Every task ends with a commit. Commit message trailer (exactly): + ``` + Co-Authored-By: Claude Fable 5.1 + Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu + ``` +- Files are CRLF, UTF-8 with no BOM. Preserve that. +- Run `dotnet test` from the worktree root; it takes ~50 s. +- No Python on this machine. Use PowerShell or the Edit tool for file surgery. + +--- + +### Task 1: Legacy `IsRemote` migration moves into the state loader + +**Why:** PR #65 made `IsRemote`'s setter promote-only (`false` is ignored) so old JSON migrates. That silently broke `SessionConfigEditor.Apply` (`s.IsRemote = false` on an SSH session is a no-op → the session keeps `Kind=Ssh` with a blank host → `FullCommandLine` throws inside `BuildSshArgs` during the next `state.json` serialisation, and every later save fails). Migration belongs in the loader; the property becomes plain API sugar. + +**Files:** +- Modify: `src/CodeShellManager/Models/ShellSession.cs` (lines 45-68, 106-112) +- Modify: `src/CodeShellManager/Models/RecentlyClosedEntry.cs` (lines 24-36, 78) +- Modify: `src/CodeShellManager/Services/StateService.cs` (`Normalize`, ~line 90) +- Modify: `src/CodeShellManager/MainWindow.xaml.cs` (`ReopenClosedSessionAsync`, lines 749-753) +- Modify: `tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs` (rewrite) +- Modify: `tests/CodeShellManager.Tests/ShellSessionTests.cs` (`IsRemote_SetTrue_PromotesKindToSsh`, ~line 95) + +**Interfaces:** +- Produces: `ShellSession.IsRemote { get; set; }` — `[JsonIgnore]`; getter `Kind == Ssh`; setter `true → Kind = Ssh`, `false → Kind = Local` only when `Kind == Ssh` (a WSL session stays WSL). +- Produces: `ShellSession.LegacyIsRemote : bool?` — JSON name `IsRemote`, `[JsonIgnore(Condition = WhenWritingNull)]`, never written after migration. +- Produces: `ShellSession.MigrateLegacyFields()` and `RecentlyClosedEntry.MigrateLegacyFields()` — idempotent, called by `StateService.Normalize`. +- Produces: `[JsonIgnore]` on `IsWsl`, `FullCommandLine`, `FolderShort`, `DefaultDisplayName`, `AccentKey` (ShellSession) and `IsRemote`, `Subtitle` (RecentlyClosedEntry). `FullCommandLine` no longer throws on incomplete sessions. + +- [ ] **Step 1: Write the failing tests** + +Replace the body of `tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs` with: + +```csharp +using System.Text.Json; +using CodeShellManager.Models; +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// State-file migration coverage. Legacy state.json predates the +/// enum and only carried IsRemote. Migration happens in +/// — the loader — not in a property setter, so the +/// in-memory setter can stay a plain two-way switch. +/// +public class ShellSessionMigrationTests +{ + private static AppState LoadState(string json) => + StateService.Normalize(JsonSerializer.Deserialize(json)!); + + [Fact] + public void Normalize_LegacyIsRemoteTrue_PromotesKindToSsh() + { + const string legacy = """ + { "Sessions": [ { "IsRemote": true, "SshUser": "alice", "SshHost": "dev.example.com" } ] } + """; + var s = LoadState(legacy).Sessions[0]; + Assert.Equal(SessionKind.Ssh, s.Kind); + Assert.True(s.IsRemote); + Assert.Null(s.LegacyIsRemote); + } + + [Fact] + public void Normalize_LegacyIsRemoteFalse_KeepsKindLocal() + { + const string legacy = """{ "Sessions": [ { "IsRemote": false, "WorkingFolder": "C:\\proj" } ] }"""; + var s = LoadState(legacy).Sessions[0]; + Assert.Equal(SessionKind.Local, s.Kind); + Assert.False(s.IsRemote); + } + + [Fact] + public void Normalize_KindWslWithStrayLegacyFalse_StaysWsl() + { + const string mixed = """{ "Sessions": [ { "Kind": 2, "IsRemote": false, "WslDistro": "Ubuntu" } ] }"""; + var s = LoadState(mixed).Sessions[0]; + Assert.Equal(SessionKind.Wsl, s.Kind); + } + + [Fact] + public void Normalize_KindWslWithStrayLegacyTrue_KindWins() + { + // Kind is authoritative once present; a stale IsRemote must not clobber it. + const string mixed = """{ "Sessions": [ { "Kind": 2, "IsRemote": true, "WslDistro": "Ubuntu" } ] }"""; + var s = LoadState(mixed).Sessions[0]; + Assert.Equal(SessionKind.Wsl, s.Kind); + } + + [Fact] + public void Normalize_RecentlyClosedLegacyIsRemote_PromotesToSsh() + { + const string legacy = """{ "RecentlyClosed": [ { "IsRemote": true, "SshHost": "h" } ] }"""; + var e = LoadState(legacy).RecentlyClosed[0]; + Assert.Equal(SessionKind.Ssh, e.Kind); + Assert.Null(e.LegacyIsRemote); + } + + [Fact] + public void Serialize_DoesNotWriteLegacyIsRemoteOrComputedProperties() + { + var s = new ShellSession { Kind = SessionKind.Ssh, SshHost = "h" }; + string json = JsonSerializer.Serialize(s); + Assert.DoesNotContain("\"IsRemote\"", json); + Assert.DoesNotContain("FullCommandLine", json); + Assert.DoesNotContain("FolderShort", json); + Assert.DoesNotContain("AccentKey", json); + Assert.Contains("\"Kind\":1", json); + } + + [Fact] + public void Serialize_IncompleteSshSession_DoesNotThrow() + { + // Regression: FullCommandLine used to be serialised and BuildSshArgs threw on a + // blank host, which made every state.json save fail after a bad edit. + var s = new ShellSession { Kind = SessionKind.Ssh, SshHost = "" }; + string json = JsonSerializer.Serialize(s); + Assert.NotNull(json); + Assert.Equal("ssh", s.FullCommandLine); + } + + [Fact] + public void IsRemoteSetter_FalseOnSsh_DemotesToLocal() + { + var s = new ShellSession { Kind = SessionKind.Ssh }; + s.IsRemote = false; + Assert.Equal(SessionKind.Local, s.Kind); + } + + [Fact] + public void IsRemoteSetter_FalseOnWsl_LeavesWsl() + { + var s = new ShellSession { Kind = SessionKind.Wsl }; + s.IsRemote = false; + 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); + } +} +``` + +In `tests/CodeShellManager.Tests/ShellSessionTests.cs` replace the test `IsRemote_SetTrue_PromotesKindToSsh` with: + +```csharp + [Fact] + public void IsRemote_SetTrue_SetsKindSsh() + { + var s = new ShellSession { IsRemote = true }; + Assert.Equal(SessionKind.Ssh, s.Kind); + Assert.True(s.IsRemote); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/CodeShellManager.Tests/ -nologo -v q --filter "FullyQualifiedName~ShellSessionMigrationTests"` +Expected: compile error (`LegacyIsRemote` does not exist) — that counts as red. + +- [ ] **Step 3: Implement in `ShellSession.cs`** + +Add `using System.Text.Json.Serialization;` at the top. Replace the block from the `Kind` doc-comment through `IsWsl` (lines 44-68) with: + +```csharp + /// + /// Authoritative session kind. Everything that branches on session type reads this. + /// Legacy state.json files (pre-Kind) only carried IsRemote; see + /// and . + /// + public SessionKind Kind { get; set; } = SessionKind.Local; + + /// + /// Convenience view of for the SSH case. Setting true makes + /// the session SSH; setting false on an SSH session makes it Local. It is + /// deliberately NOT persisted — is — and it carries no migration + /// logic. A WSL session is unaffected by IsRemote = false. + /// + [JsonIgnore] + public bool IsRemote + { + get => Kind == SessionKind.Ssh; + set + { + if (value) Kind = SessionKind.Ssh; + else if (Kind == SessionKind.Ssh) Kind = SessionKind.Local; + } + } + + /// + /// Read-only compatibility slot for the pre- "IsRemote" JSON + /// key. Populated only when an old file is deserialised; + /// folds it into and nulls it so it is never written back. + /// + [JsonPropertyName("IsRemote")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? LegacyIsRemote { get; set; } + + /// + /// Folds legacy JSON fields into their current representation. Idempotent. Called by + /// StateService.Normalize for every loaded or imported session — the loader is + /// the one place that knows it is looking at possibly-old data. + /// + public void MigrateLegacyFields() + { + if (LegacyIsRemote == true && Kind == SessionKind.Local) Kind = SessionKind.Ssh; + LegacyIsRemote = null; + } + + /// True iff this session runs inside a WSL distro via wsl.exe. + [JsonIgnore] + public bool IsWsl => Kind == SessionKind.Wsl; +``` + +Replace `FullCommandLine` (lines 106-112) with a non-throwing version and mark it ignored: + +```csharp + /// + /// Full command line for display. Never throws: an incomplete session (blank SSH host, + /// blank WSL distro) shows just the executable — this string is used in error dialogs + /// on exactly those paths. + /// + [JsonIgnore] + public string FullCommandLine => Kind switch + { + SessionKind.Ssh => string.IsNullOrWhiteSpace(SshHost) ? "ssh" : $"ssh {BuildSshArgs()}", + SessionKind.Wsl => string.IsNullOrWhiteSpace(WslDistro) ? "wsl.exe" : $"wsl.exe {BuildWslArgs()}", + _ => string.IsNullOrWhiteSpace(Args) ? Command : $"{Command} {Args}", + }; +``` + +Add `[JsonIgnore]` immediately above `FolderShort`, `DefaultDisplayName`, and `AccentKey`. + +- [ ] **Step 4: Implement in `RecentlyClosedEntry.cs`** + +Add `using System.Text.Json.Serialization;`. Replace the `Kind`/`IsRemote` block (lines 24-36) with: + +```csharp + /// + /// Kind of the closed session, so a reopened WSL or SSH session comes back as the same + /// kind instead of Local at a UNC. Legacy entries carried only IsRemote; see + /// . + /// + public SessionKind Kind { get; set; } = SessionKind.Local; + + [JsonIgnore] + public bool IsRemote => Kind == SessionKind.Ssh; + + /// Legacy "IsRemote" JSON slot — see . + [JsonPropertyName("IsRemote")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? LegacyIsRemote { get; set; } + + public void MigrateLegacyFields() + { + if (LegacyIsRemote == true && Kind == SessionKind.Local) Kind = SessionKind.Ssh; + LegacyIsRemote = null; + } +``` + +Delete the line `IsRemote = s.IsRemote,` inside `FromSession`. Add `[JsonIgnore]` above `Subtitle`. Fix `tests/CodeShellManager.Tests/RecentlyClosedEntryTests.cs` object initialisers that use `IsRemote = true` → `Kind = SessionKind.Ssh` (the property is now get-only on the entry; the assertion `Assert.True(e.IsRemote)` still works). + +- [ ] **Step 5: Implement in `StateService.Normalize`** + +```csharp + internal static AppState Normalize(AppState s) + { + s.Sessions ??= []; + s.Groups ??= []; + s.RecentlyClosed ??= []; + s.GroupLayouts ??= new(); + s.Settings ??= new(); + // Legacy-field migration lives here, in the loader, so the models stay free of + // deserialisation-order tricks. Import goes through this too (ImportExportService). + foreach (var session in s.Sessions) session.MigrateLegacyFields(); + foreach (var entry in s.RecentlyClosed) entry.MigrateLegacyFields(); + return s; + } +``` + +- [ ] **Step 6: Remove the setter-based migration in `MainWindow.ReopenClosedSessionAsync`** + +Replace lines 749-753 (the two comments and the `if (entry.Kind == Models.SessionKind.Local) session.IsRemote = entry.IsRemote;` line) with just: + +```csharp + session.Kind = entry.Kind; +``` + +- [ ] **Step 7: Build, run the full suite** + +Run: `dotnet build src/CodeShellManager/CodeShellManager.csproj -nologo -v q` → `Build succeeded`, no new warnings. +Run: `dotnet test tests/CodeShellManager.Tests/ -nologo -v q` → all pass (expect ~408). + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "refactor(sessions): migrate legacy IsRemote in the state loader, not a setter + +The promote-only setter from #65 ignored false, so SessionConfigEditor.Apply +could never flip an SSH session back to Local, and the still-serialised +FullCommandLine then threw on every save. Kind is the only persisted field; +IsRemote is a [JsonIgnore]d two-way convenience; the legacy JSON key lands in +LegacyIsRemote and StateService.Normalize folds it into Kind. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 2: `SessionConfigDraft` / `SessionConfigEditor` learn `Kind` and the WSL fields + +**Files:** +- Modify: `src/CodeShellManager/Models/SessionConfigDraft.cs` +- Modify: `src/CodeShellManager/Services/SessionConfigEditor.cs` +- Modify: `src/CodeShellManager/Views/NewSessionDialog.xaml.cs` (`ToDraft`, minimal compile fix — Task 3 finishes the dialog) +- Modify: `tests/CodeShellManager.Tests/SessionConfigEditorTests.cs` (fixtures at lines 18-26, usages of `d.IsRemote` at 98, 221, 276) + +**Interfaces:** +- Consumes: `ShellSession.Kind`, `WslDiscoveryService.ToUncPath(distro, linuxPath)`. +- Produces: `SessionConfigDraft.Kind : SessionKind`, `WslDistro`, `WslUser`, `WslWorkingFolder : string`. `IsRemote` stays on the draft as `=> Kind == SessionKind.Ssh` (read-only) so existing call sites compile. +- Produces: `SessionConfigChange.WorkingFolderChanged` is also true when a WSL session's distro or Linux folder changed (git info must be re-resolved). +- Produces: `SessionConfigEditor.Apply` writes `s.Kind = d.Kind`; for WSL it derives `s.WorkingFolder = WslDiscoveryService.ToUncPath(d.WslDistro, d.WslWorkingFolder)` — the UNC-mirror invariant every WSL code path relies on. + +- [ ] **Step 1: Write the failing tests** + +Change the fixture in `SessionConfigEditorTests.cs` so `RemoteSession()` uses `Kind = SessionKind.Ssh` instead of `IsRemote = true`, and add a third fixture plus these tests (append inside the class): + +```csharp + private static ShellSession WslSession() => new() + { + Name = "ubuntu proj", + Kind = SessionKind.Wsl, + WslDistro = "Ubuntu", + WslUser = "alice", + WslWorkingFolder = "/home/alice/proj", + WorkingFolder = @"\\wsl$\Ubuntu\home\alice\proj", + Command = "claude", + }; + + [Fact] + public void Apply_SshToLocal_DemotesKind() + { + // Regression: with the promote-only IsRemote setter this silently left Kind=Ssh. + var s = RemoteSession(); + var d = SessionConfigDraft.FromSession(s); + d.Kind = SessionKind.Local; + d.WorkingFolder = @"C:\src"; + + Assert.True(SessionConfigEditor.Diff(s, d).RequiresRelaunch); + SessionConfigEditor.Apply(s, d); + + Assert.Equal(SessionKind.Local, s.Kind); + Assert.False(s.IsRemote); + Assert.Equal(@"C:\src", s.WorkingFolder); + } + + [Fact] + public void Diff_WslIdentical_NoChange() + { + var s = WslSession(); + Assert.False(SessionConfigEditor.Diff(s, SessionConfigDraft.FromSession(s)).AnyChange); + } + + [Fact] + public void Diff_WslDistroChanged_RequiresRelaunchAndFolderChanged() + { + var s = WslSession(); + var d = SessionConfigDraft.FromSession(s); + d.WslDistro = "Debian"; + var c = SessionConfigEditor.Diff(s, d); + Assert.True(c.AnyChange); + Assert.True(c.RequiresRelaunch); + Assert.True(c.WorkingFolderChanged); + } + + [Fact] + public void Diff_WslUserChanged_RequiresRelaunchButFolderUnchanged() + { + var s = WslSession(); + var d = SessionConfigDraft.FromSession(s); + d.WslUser = "root"; + var c = SessionConfigEditor.Diff(s, d); + Assert.True(c.RequiresRelaunch); + Assert.False(c.WorkingFolderChanged); + } + + [Fact] + public void Diff_WslLinuxFolderTrailingSlash_IsNotAChange() + { + var s = WslSession(); + var d = SessionConfigDraft.FromSession(s); + d.WslWorkingFolder = "/home/alice/proj/"; + Assert.False(SessionConfigEditor.Diff(s, d).AnyChange); + } + + [Fact] + public void Apply_WslFolderChanged_ResyncsUncWorkingFolder() + { + var s = WslSession(); + var d = SessionConfigDraft.FromSession(s); + d.WslWorkingFolder = "/srv/other"; + SessionConfigEditor.Apply(s, d); + Assert.Equal("/srv/other", s.WslWorkingFolder); + Assert.Equal(@"\\wsl$\Ubuntu\srv\other", s.WorkingFolder); + } + + [Fact] + public void Apply_LocalToWsl_SetsKindAndUnc() + { + var s = LocalSession(); + var d = SessionConfigDraft.FromSession(s); + d.Kind = SessionKind.Wsl; + d.WslDistro = "Ubuntu"; + d.WslWorkingFolder = "/home/alice"; + Assert.True(SessionConfigEditor.Diff(s, d).RequiresRelaunch); + SessionConfigEditor.Apply(s, d); + Assert.Equal(SessionKind.Wsl, s.Kind); + Assert.Equal(@"\\wsl$\Ubuntu\home\alice", s.WorkingFolder); + } + + [Fact] + public void Diff_StaleWslFieldsOnLocalSession_DoNotCount() + { + var s = LocalSession(); + s.WslDistro = "leftover"; + var d = SessionConfigDraft.FromSession(s); + d.WslDistro = ""; + Assert.False(SessionConfigEditor.Diff(s, d).AnyChange); + } +``` + +Update the three existing usages: `d.IsRemote = true;` → `d.Kind = SessionKind.Ssh;`, and the object initialiser at ~line 221 `IsRemote = false,` → `Kind = SessionKind.Local,`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/CodeShellManager.Tests/ -nologo -v q --filter "FullyQualifiedName~SessionConfigEditorTests"` +Expected: compile error on `d.Kind` / `WslDistro`. + +- [ ] **Step 3: Implement `SessionConfigDraft`** + +Replace the `// Remote` block and `FromSession` in `SessionConfigDraft.cs`: + +```csharp + // Kind + kind-specific fields. Kind is authoritative; IsRemote is a read-only view. + public SessionKind Kind { get; set; } = SessionKind.Local; + public bool IsRemote => 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; } = ""; +``` + +and in `FromSession` replace `IsRemote = s.IsRemote,` with: + +```csharp + Kind = s.Kind, + WslDistro = s.WslDistro, + WslUser = s.WslUser, + WslWorkingFolder = s.WslWorkingFolder, +``` + +- [ ] **Step 4: Implement `SessionConfigEditor.Diff` / `Apply`** + +In `Diff`, replace the `modeChanged`, `folderChanged`, `sshChanged` block with: + +```csharp + bool modeChanged = d.Kind != s.Kind; + + // Kind-specific fields only count while the kind is unchanged — a kind flip already + // forces a relaunch, and leftovers from a previous kind must not read as edits. + bool sameKind = !modeChanged; + bool folderChanged = sameKind && s.Kind == SessionKind.Local + && !PathsEqual(d.WorkingFolder, s.WorkingFolder); + + bool sshChanged = sameKind && s.Kind == SessionKind.Ssh + && (!Eq(d.SshUser, s.SshUser) + || !Eq(d.SshHost, s.SshHost) + || d.SshPort != s.SshPort + || !Eq(d.SshRemoteFolder, s.SshRemoteFolder)); + + bool wslFolderChanged = sameKind && s.Kind == SessionKind.Wsl + && (!Eq(d.WslDistro, s.WslDistro) + || !LinuxPathsEqual(d.WslWorkingFolder, s.WslWorkingFolder)); + bool wslChanged = wslFolderChanged + || (sameKind && s.Kind == SessionKind.Wsl && !Eq(d.WslUser, s.WslUser)); +``` + +then include them: `anyChange = modeChanged || folderChanged || sshChanged || wslChanged || launchChanged || appearanceChanged || !Eq(d.Name, s.Name);`, `requiresRelaunch = modeChanged || folderChanged || sshChanged || wslChanged || launchChanged || transparencyChanged || overridesCleared;` and return `WorkingFolderChanged: folderChanged || wslFolderChanged`. + +Add the helper next to `PathsEqual`: + +```csharp + /// Linux path compare: exact, trailing-slash tolerant, case-sensitive (ext4 is). + internal static bool LinuxPathsEqual(string a, string b) => + string.Equals((a ?? "").Trim().TrimEnd('/'), (b ?? "").Trim().TrimEnd('/'), StringComparison.Ordinal); +``` + +In `Apply`, replace `s.IsRemote = d.IsRemote; s.WorkingFolder = d.WorkingFolder;` with: + +```csharp + s.Kind = d.Kind; + s.WslDistro = d.WslDistro; + s.WslUser = d.WslUser; + s.WslWorkingFolder = d.WslWorkingFolder.Trim(); + // WSL sessions keep WorkingFolder as the \\wsl$ UNC mirror of the Linux path so + // Explorer, git polling and the sidebar need no special-casing (see CLAUDE.md + // "WSL Sessions"). Derive it here so the two can never drift apart. + s.WorkingFolder = d.Kind == SessionKind.Wsl + ? WslDiscoveryService.ToUncPath(d.WslDistro, s.WslWorkingFolder) + : d.WorkingFolder; +``` + +Update the `` doc to say "local folder or WSL distro/Linux folder moved". + +- [ ] **Step 5: Build + full tests** + +Run: `dotnet build src/CodeShellManager/CodeShellManager.csproj -nologo -v q` — expect a compile error in `NewSessionDialog.ToDraft` (`IsRemote = IsRemote` on a get-only property). Fix minimally now so the build passes: in `NewSessionDialog.ToDraft()` replace `IsRemote = IsRemote,` with + +```csharp + Kind = IsWsl ? SessionKind.Wsl : IsRemote ? SessionKind.Ssh : SessionKind.Local, + WslDistro = WslDistro, + WslUser = WslUser, + WslWorkingFolder = WslWorkingFolder, +``` + +(`IsWsl`, `IsRemote`, `WslDistro`… here are the dialog's own output properties, set in `Start_Click`). Add `using CodeShellManager.Models;` if missing. +Run: `dotnet test tests/CodeShellManager.Tests/ -nologo -v q` → all pass. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat(edit-session): carry Kind and WSL fields through SessionConfigDraft + +Diff treats distro/user/Linux-folder like the SSH target fields; Apply sets +Kind directly and re-derives the UNC WorkingFolder so it can't drift from +WslWorkingFolder. Adds the SSH->Local regression test. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 3: "Edit session…" works for WSL sessions (dialog + MainWindow) + +**Files:** +- Modify: `src/CodeShellManager/Views/NewSessionDialog.xaml.cs` (`_preselectWslDistro` ~line 88, ctor `Loaded` ~line 174-180, `PopulateWslDistrosAsync` ~line 187, `ForEdit` ~line 216, `ApplyEditMode` ~line 237, `SetUpEditModeProfileCombo` ~line 286, `SessionType_Changed` ~line 460) +- Modify: `src/CodeShellManager/MainWindow.xaml.cs` (`EditSessionAsync` lines 4574-4585; boot label line ~1330) + +**Interfaces:** +- Consumes: `SessionConfigDraft.Kind/WslDistro/WslUser/WslWorkingFolder` (Task 2). +- Produces: `NewSessionDialog.ForEdit(session, …)` pre-fills WSL mode (distro pre-selected once the async list loads, user + Linux folder boxes filled). Profile/appearance panel is shown for Local **and** WSL sessions (xterm appearance is independent of what runs inside), hidden only for SSH — same rule in create and edit mode. + +- [ ] **Step 1: Make the preselect field assignable** + +Change `private readonly string _preselectWslDistro = "";` to `private string _preselectWslDistro = "";`. + +- [ ] **Step 2: `ForEdit` passes the local folder only for Local sessions** + +```csharp + defaultFolder: session.Kind == SessionKind.Local ? session.WorkingFolder : "", +``` + +- [ ] **Step 3: `ApplyEditMode` handles all three kinds** + +Replace the `if (s.IsRemote) { … }` block with: + +```csharp + switch (s.Kind) + { + case SessionKind.Ssh: + // Checking the radio runs SessionType_Changed, which swaps the panels. It no + // longer blanks NameBox — that handler returns early in edit mode — so the + // assignment below is the only thing setting the name, not a repair. + RemoteRadio.IsChecked = true; + SshHostBox.Text = string.IsNullOrWhiteSpace(s.SshUser) + ? s.SshHost + : $"{s.SshUser}@{s.SshHost}"; + SshPortBox.Text = s.SshPort.ToString(); + SshRemoteFolderBox.Text = s.SshRemoteFolder; + break; + case SessionKind.Wsl: + WslRadio.IsChecked = true; + WslUserBox.Text = s.WslUser; + WslWorkingFolderBox.Text = s.WslWorkingFolder; + // The distro combo is filled asynchronously on Loaded; PopulateWslDistrosAsync + // selects this name once the list arrives. + _preselectWslDistro = s.WslDistro; + break; + } +``` + +- [ ] **Step 4: `Loaded` populates distros in edit mode too** + +Replace the `Loaded += …` lambda body with: + +```csharp + Loaded += async (_, _) => + { + // The distro list is needed in every mode the WSL radio can be reached from, + // including edit mode — otherwise editing a WSL session shows an empty combo. + await PopulateWslDistrosAsync(); + // Sibling-worktree fan-out only makes sense when creating sessions. + if (IsEditMode) return; + if (IsLocalMode && !string.IsNullOrWhiteSpace(FolderBox.Text)) + await ProbeSiblingWorktreesAsync(FolderBox.Text.Trim()); + }; +``` + +In `PopulateWslDistrosAsync`, after the list is built (including the `distros.Count == 0` case), if `_preselectWslDistro` is non-empty and no item matched it, add `new ComboBoxItem { Content = $"{_preselectWslDistro} (not installed)", Tag = _preselectWslDistro }` and select it, so editing a session whose distro was removed does not silently wipe the distro on Save. Keep the "No WSL distros found" hint when the list was empty. + +- [ ] **Step 5: Profile panel rule — hide only for SSH** + +In `SessionType_Changed`: `ProfilePanel.Visibility = IsRemoteMode ? Visibility.Collapsed : Visibility.Visible;` and update the comment to "Appearance overrides apply to any xterm-hosted session, WSL included; SSH is excluded because the remote profile is out of our hands." In `SetUpEditModeProfileCombo`: `ProfilePanel.Visibility = s.Kind == SessionKind.Ssh ? Visibility.Collapsed : Visibility.Visible;`. + +- [ ] **Step 6: MainWindow edit flow compares Kind; boot label per kind** + +In `EditSessionAsync` replace `bool wasRemote = session.IsRemote;` with `var wasKind = session.Kind;` and the condition with `if (change.WorkingFolderChanged || session.Kind != wasKind)`. + +At line ~1330 replace the `bootLabel` expression with: + +```csharp + string bootLabel = session.Kind switch + { + Models.SessionKind.Ssh => $"Connecting to {session.SshHost}…", + Models.SessionKind.Wsl => $"Starting {session.WslDistro}…", + _ => $"Starting {(string.IsNullOrWhiteSpace(session.Command) ? "session" : session.Command)}…", + }; +``` + +- [ ] **Step 7: Build + full tests** + +Run build and tests as in Global Constraints. Expected: `Build succeeded`, all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "feat(edit-session): edit WSL sessions in the shared New Session form + +ForEdit pre-selects the distro and fills user/Linux folder, the distro list is +populated in edit mode, and the appearance panel is available for WSL sessions +(only SSH hides it). Boot label names the distro. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 4: One MSVCRT-correct quoting helper; one `BuildWslArgs` + +**Why:** `QuoteForCmd` and both `BuildWslArgs` bodies escape `"` as `\"` but never double the backslashes that precede a quote (or the closing quote). Verified against `CommandLineToArgvW`: `sed -i 's/\"//g' f.txt` splits into stray argv; a command ending in `\` swallows the closing quote. + +**Files:** +- Modify: `src/CodeShellManager/Models/ShellSession.cs` (`BuildWslArgs`, `QuoteForCmd`, lines ~140-175) +- Modify: `src/CodeShellManager/Services/RunInstance.cs` (`BuildWslArgs`, lines 262-280; `Start`, lines 75-128) +- Create: `tests/CodeShellManager.Tests/Win32CommandLineTests.cs` +- Modify: `tests/CodeShellManager.Tests/ShellSessionTests.cs`, `tests/CodeShellManager.Tests/RunInstanceTests.cs` (only if an existing expectation encoded the buggy shape — the plain-quote cases keep their current expected strings) + +**Interfaces:** +- Produces: `ShellSession.QuoteForCmd(string value, bool force = false)` — MSVCRT rules: 2n backslashes before `"` → n literal backslashes + escaped quote; trailing backslashes doubled before the closing quote; `force` always wraps in quotes. +- Produces: `ShellSession.BuildWslArgs(string? inner = null)` — `inner` replaces the `Command + Args` payload (used for run commands). Throws `InvalidOperationException` on blank `WslDistro` (both callers now agree). +- Produces: `RunInstance.BuildWslArgs(parent, commandLine)` delegates to `parent.BuildWslArgs(commandLine)`. + +- [ ] **Step 1: Write the failing round-trip tests** + +Create `tests/CodeShellManager.Tests/Win32CommandLineTests.cs`: + +```csharp +using System; +using System.Runtime.InteropServices; +using CodeShellManager.Models; +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// Round-trips our quoting through the real Win32 tokenizer. wsl.exe is started via +/// CreateProcess with no outer shell, so what CommandLineToArgvW produces is exactly what +/// wsl.exe (and then bash -lc) receives. Hand-written expectations were how the original +/// backslash bug slipped through. +/// +public class Win32CommandLineTests +{ + [DllImport("shell32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr CommandLineToArgvW(string lpCmdLine, out int pNumArgs); + + [DllImport("kernel32.dll")] + private static extern IntPtr LocalFree(IntPtr hMem); + + internal static string[] Split(string commandLine) + { + // Prefix a dummy program name: CommandLineToArgvW parses argv[0] with different rules. + IntPtr argv = CommandLineToArgvW("x.exe " + commandLine, out int argc); + if (argv == IntPtr.Zero) throw new InvalidOperationException("CommandLineToArgvW failed"); + try + { + var result = new string[argc - 1]; + for (int i = 1; i < argc; i++) + result[i - 1] = Marshal.PtrToStringUni(Marshal.ReadIntPtr(argv, i * IntPtr.Size))!; + return result; + } + finally { LocalFree(argv); } + } + + [Theory] + [InlineData("plain")] + [InlineData("has space")] + [InlineData("say \"hi\"")] + [InlineData("trailing\\")] + [InlineData("back\\\"slash-quote")] + [InlineData("two\\\\\"bs")] + [InlineData("sed -i 's/\\\"//g' f.txt")] + [InlineData("grep -r \"\\\"foo\\\"\" .")] + [InlineData("cp -r /src /dst\\")] + [InlineData("")] + public void QuoteForCmd_RoundTripsThroughCommandLineToArgvW(string value) + { + string[] argv = Split(ShellSession.QuoteForCmd(value, force: true)); + Assert.Single(argv); + Assert.Equal(value, argv[0]); + } + + [Theory] + [InlineData("cargo test")] + [InlineData("echo \"hi\"")] + [InlineData("sed -i 's/\\\"//g' f.txt")] + [InlineData("cp -r /src /dst\\")] + [InlineData("printf '%s\\n' \"$HOME\"")] + public void RunInstanceBuildWslArgs_BashPayloadArrivesIntact(string commandLine) + { + var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu", WslWorkingFolder = "/home/a b" }; + string[] argv = Split(RunInstance.BuildWslArgs(p, commandLine)); + Assert.Equal(new[] { "-d", "Ubuntu", "--cd", "/home/a b", "--", "bash", "-lc", commandLine }, argv); + } + + [Fact] + public void ShellSessionBuildWslArgs_DistroWithSpaceAndUser_Tokenizes() + { + var s = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "My Distro", WslUser = "alice", + WslWorkingFolder = "/home/alice", Command = "claude", Args = "--prompt \"fix the \\\"foo\\\" bug\"", + }; + string[] argv = Split(s.BuildWslArgs()); + Assert.Equal(new[] { "-d", "My Distro", "-u", "alice", "--cd", "/home/alice", "--", "bash", "-lc", + "claude --prompt \"fix the \\\"foo\\\" bug\"" }, argv); + } + + [Fact] + public void RunInstanceBuildWslArgs_BlankDistro_Throws() + { + var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "" }; + Assert.Throws(() => RunInstance.BuildWslArgs(p, "ls")); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/CodeShellManager.Tests/ -nologo -v q --filter "FullyQualifiedName~Win32CommandLineTests"` +Expected: compile error (`force` parameter missing); after adding only the parameter, the `trailing\` / `sed` cases fail. + +- [ ] **Step 3: Implement `QuoteForCmd` and unify `BuildWslArgs` in `ShellSession.cs`** + +```csharp + /// + /// Win32 (MSVCRT / CommandLineToArgvW) argument quoting. Space-free, quote-free values + /// are returned unchanged unless is set. Inside quotes, a + /// run of n backslashes followed by " becomes 2n+1 backslashes + quote, and a + /// trailing run of n backslashes becomes 2n so it cannot eat the closing quote. + /// Every value that reaches wsl.exe goes through here — no ad-hoc Replace. + /// + internal static string QuoteForCmd(string value, bool force = false) + { + value ??= ""; + if (!force && value.Length > 0 && value.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) + return value; + + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + int backslashes = 0; + foreach (char c in value) + { + if (c == '\\') { backslashes++; continue; } + if (c == '"') + { + sb.Append('\\', backslashes * 2 + 1).Append('"'); + backslashes = 0; + continue; + } + sb.Append('\\', backslashes).Append(c); + backslashes = 0; + } + sb.Append('\\', backslashes * 2); + sb.Append('"'); + return sb.ToString(); + } + + /// + /// Builds the argument string passed to wsl.exe: + /// -d <distro> [-u <user>] [--cd <linux-folder>] -- bash -lc "<payload>". + /// The payload is + , or + /// when given (run commands). It is wrapped in bash -lc so PATH-resolved tools + /// (nvm node, pyenv, …) behave as in a login shell; bash then interprets the payload as + /// a shell command line, which is the intent. Throws when is + /// blank — callers validate first (LaunchValidationError, Task 5). + /// + internal string BuildWslArgs(string? inner = null) + { + if (string.IsNullOrWhiteSpace(WslDistro)) + throw new InvalidOperationException("WslDistro must be set for WSL sessions."); + var sb = new StringBuilder(); + sb.Append("-d ").Append(QuoteForCmd(WslDistro)); + if (!string.IsNullOrWhiteSpace(WslUser)) + sb.Append(" -u ").Append(QuoteForCmd(WslUser)); + if (!string.IsNullOrWhiteSpace(WslWorkingFolder)) + sb.Append(" --cd ").Append(QuoteForCmd(WslWorkingFolder)); + if (inner is null) + { + var shell = string.IsNullOrWhiteSpace(Command) ? "bash" : Command; + inner = string.IsNullOrWhiteSpace(Args) ? shell : $"{shell} {Args}"; + } + sb.Append(" -- bash -lc ").Append(QuoteForCmd(inner, force: true)); + return sb.ToString(); + } +``` + +- [ ] **Step 4: `RunInstance.BuildWslArgs` delegates; `Start` fails gracefully** + +Replace the whole `RunInstance.BuildWslArgs` method with: + +```csharp + /// + /// wsl.exe args for a run inside the parent's distro. One implementation with the + /// session launcher — see — so the two can't + /// disagree about quoting or about a blank distro. + /// + internal static string BuildWslArgs(ShellSession parent, string commandLine) + => parent.BuildWslArgs(commandLine); +``` + +In `RunInstance.Start`, wrap the `switch (parent.Kind) { … }` and `_pty.Start(…)` in a `try`. In the `catch (Exception ex)`: append `$"Cannot start: {ex.Message}\r\n"` to the ANSI-stripped buffer (reuse whatever method already appends to `_ansiStripped` under `_bufLock` and raises `OutputChanged` — search the file; add a small private `AppendText(string)` if there is only inline code), set `ExitCode = -1`, `EndedAt = DateTime.Now`, `State` to the failed member of `RunState` (check the enum), raise `StateChanged`, detach the two PTY handlers, `_pty.Dispose()`, `_pty = null`, and return. A run that cannot even build its command line must show as a failed chip, not throw out of the toolbar click. + +- [ ] **Step 5: Reconcile existing tests** + +Run the full suite. If any pre-existing `BuildWslArgs`/`QuoteForCmd` expectations differ **only** in the buggy escaping shape, update them to the round-tripped output. Expectations that assert plain `"..."` wrapping (`-d Debian -- bash -lc "ls"`) still hold. + +- [ ] **Step 6: Build + full tests → commit** + +```bash +git add -A +git commit -m "fix(wsl): MSVCRT-correct quoting, one BuildWslArgs for sessions and runs + +QuoteForCmd now doubles backslashes before quotes and at the end of a quoted +value; verified by round-tripping through CommandLineToArgvW. RunInstance +delegates to ShellSession.BuildWslArgs and reports a start failure in the run +output instead of throwing from the toolbar click. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 5: Validate a session before creating any UI for it + +**Why:** `session.BuildWslArgs()` (and `BuildSshArgs()`) run at MainWindow.xaml.cs:1370-1379, *after* the WebView2 and terminal wrapper exist and *outside* the `try` at 1419. A `state.json`/import entry with `Kind: 2, WslDistro: ""` leaks a pane and a stuck launching placeholder on every restore. + +**Files:** +- Modify: `src/CodeShellManager/Models/ShellSession.cs` (add `LaunchValidationError`) +- Modify: `src/CodeShellManager/MainWindow.xaml.cs` (`LaunchSessionAsync` top, ~line 1261) +- Modify: `tests/CodeShellManager.Tests/ShellSessionTests.cs` + +**Interfaces:** +- Produces: `ShellSession.LaunchValidationError : string?` — `[JsonIgnore]`; null when launchable; otherwise a user-facing sentence. + +- [ ] **Step 1: Tests** + +Append to `ShellSessionTests`: + +```csharp + [Fact] + public void LaunchValidationError_Local_IsNull() => + Assert.Null(new ShellSession { Kind = SessionKind.Local, Command = "claude" }.LaunchValidationError); + + [Fact] + public void LaunchValidationError_SshBlankHost_Reports() => + Assert.Contains("host", new ShellSession { Kind = SessionKind.Ssh }.LaunchValidationError!, StringComparison.OrdinalIgnoreCase); + + [Fact] + public void LaunchValidationError_WslBlankDistro_Reports() => + Assert.Contains("distro", new ShellSession { Kind = SessionKind.Wsl }.LaunchValidationError!, StringComparison.OrdinalIgnoreCase); + + [Fact] + public void LaunchValidationError_WslWithDistro_IsNull() => + Assert.Null(new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu" }.LaunchValidationError); +``` + +- [ ] **Step 2: Run → fail (compile error).** + +- [ ] **Step 3: Implement** + +In `ShellSession.cs`, after `FullCommandLine`: + +```csharp + /// + /// Null when the session has everything it needs to launch; otherwise a sentence for + /// the user. Checked at the top of MainWindow.LaunchSessionAsync BEFORE any + /// WebView2 or PTY is created, because the arg builders throw on these and a throw at + /// that point leaks the pane. state.json and imports are untrusted input. + /// + [JsonIgnore] + public string? LaunchValidationError => Kind switch + { + SessionKind.Ssh when string.IsNullOrWhiteSpace(SshHost) => "This SSH session has no host. Edit the session and set one.", + SessionKind.Wsl when string.IsNullOrWhiteSpace(WslDistro) => "This WSL session has no distro. Edit the session and pick one.", + _ => null, + }; +``` + +In `LaunchSessionAsync`, right after the opening `Log($"LaunchSession START…")` line, add: + +```csharp + if (session.LaunchValidationError is { } validationError) + { + Log($"LaunchSession REFUSED: {validationError}"); + MessageBox.Show(this, $"Cannot start '{session.Name}'.\n\n{validationError}", + "Launch Error", MessageBoxButton.OK, MessageBoxImage.Warning); + if (removeOnFailure) _sessionManager.RemoveSession(session.Id); + else { session.IsDormant = true; AddDormantSidebarItem(session); } + if (_launchingSidebarItems.Remove(session.Id)) RebuildSidebarOrder(); + return; + } +``` + +Confirm `AddDormantSidebarItem(ShellSession)` exists with that signature (CLAUDE.md "Sleep / Wake"). With `removeOnFailure: true` (the default, used by restore) a broken restored session is dropped with the message above — the same outcome as a failed PTY start today. + +- [ ] **Step 4: Build + full tests → commit** + +```bash +git add -A +git commit -m "fix(launch): refuse unlaunchable sessions before creating a pane + +A WSL session with a blank distro (or SSH with a blank host) used to throw from +BuildWslArgs after the WebView2 and wrapper existed and outside the failure +handler, leaking both. Validate first via ShellSession.LaunchValidationError. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 6: Git polling off the UI thread, with a WSL-aware cadence + +**Why:** `GitService.GetGitInfoAsync`/`GetRepoRootAsync` start with a synchronous `Directory.Exists` on the caller's thread, and `SessionViewModel.RefreshGitInfoAsync` runs on the dispatcher (constructor + `PeriodicTimer` continuation). On a `\\wsl$` path with a stopped distro that call boots the VM and freezes the UI for seconds, at startup and every 10 s. Also, a WSL session in a non-repo folder spawns a third `wsl.exe` every tick because a null `RepoRoot` is re-probed forever. + +**Files:** +- Modify: `src/CodeShellManager/ViewModels/SessionViewModel.cs` (lines 79-97, 111-120, `ReloadGitInfoAsync`) +- Create: `tests/CodeShellManager.Tests/SessionViewModelGitPollingTests.cs` + +**Interfaces:** +- Produces: `SessionViewModel.GitPollIntervalFor(SessionKind) : TimeSpan` — `internal static`; 10 s Local, 30 s Wsl. +- Produces: negative RepoRoot cache for WSL only (`_repoRootProbedNegative`), reset by `ReloadGitInfoAsync`. + +- [ ] **Step 1: Tests** + +```csharp +using System; +using CodeShellManager.Models; +using CodeShellManager.ViewModels; +using Xunit; + +namespace CodeShellManager.Tests; + +public class SessionViewModelGitPollingTests +{ + [Fact] + public void GitPollInterval_LocalIsTenSeconds() => + Assert.Equal(TimeSpan.FromSeconds(10), SessionViewModel.GitPollIntervalFor(SessionKind.Local)); + + [Fact] + public void GitPollInterval_WslIsSlower() + { + // Each WSL probe is a wsl.exe spawn (LxssManager hop) and keeps the VM awake; + // three times the local cadence is the documented trade. + Assert.Equal(TimeSpan.FromSeconds(30), SessionViewModel.GitPollIntervalFor(SessionKind.Wsl)); + } +} +``` + +- [ ] **Step 2: Run → compile failure.** + +- [ ] **Step 3: Implement** + +In `SessionViewModel.cs`: + +```csharp + /// + /// Git poll cadence per kind. WSL probes spawn wsl.exe (much heavier than a local git + /// spawn) and defeat WSL2's idle-VM shutdown, so they run a third as often. + /// + internal static TimeSpan GitPollIntervalFor(SessionKind kind) => + kind == SessionKind.Wsl ? TimeSpan.FromSeconds(30) : TimeSpan.FromSeconds(10); + + // WSL only: a "not a repo" answer costs a wsl.exe spawn per tick, so remember it. + // Local folders keep re-probing (a `git init` should be picked up within a tick). + private bool _repoRootProbedNegative; + + public async Task RefreshGitInfoAsync() + { + // SSH sessions have no local working folder to inspect. WSL sessions store + // 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 || _gitOverriddenByOsc) return; + + // Off the dispatcher: GitService begins with a synchronous Directory.Exists, and on + // a \\wsl$ share that boots a stopped distro (seconds). Continuations return to the + // captured UI context, so the property sets below stay on the UI thread. + string folder = Session.WorkingFolder; + var (branch, isDirty) = await Task.Run(() => GitService.GetGitInfoAsync(folder)); + GitBranch = branch; + GitIsDirty = isDirty; + GitInfoLoaded = true; + + // RepoRoot is stable for the life of the session — resolve it once. Don't gate on + // a non-empty branch: detached HEADs report no branch but are still valid repos + // that should participate in sibling detection, shared accent color, and clusters. + if (RepoRoot == null && !_repoRootProbedNegative) + { + RepoRoot = await Task.Run(() => GitService.GetRepoRootAsync(folder)); + if (RepoRoot == null && Session.Kind == SessionKind.Wsl) _repoRootProbedNegative = true; + } + } +``` + +In `PollGitInfoAsync`: `using var timer = new PeriodicTimer(GitPollIntervalFor(Session.Kind));`. + +In `ReloadGitInfoAsync` (it already clears `RepoRoot` and `_gitOverriddenByOsc`), also set `_repoRootProbedNegative = false;`. + +- [ ] **Step 4: Build + full tests → commit** + +```bash +git add -A +git commit -m "perf(git): probe off the UI thread; slower cadence and negative cache for WSL + +Directory.Exists on a wsl share boots a stopped distro and was running on the +dispatcher at startup and every tick. WSL sessions now poll every 30s and +remember a not-a-repo answer instead of spawning wsl.exe for it forever. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 7: One UNC parser; distro-name boundary in the arg translator + +**Why:** `TranslateUncArgsToLinux`'s regex has no boundary after the distro name, so with distro `Ubuntu` the UNC `\\wsl$\Ubuntu-22.04\home\alice\x` is rewritten to `/-22.04\home\alice\x` — and `git worktree add` inside Ubuntu then creates that directory. Separately, `NewSessionDialog.ParseWslUncPath` and `GitService.TryParseWslUnc` are two parsers of the same shape with different root conventions. + +**Files:** +- Modify: `src/CodeShellManager/Services/WslDiscoveryService.cs` (add `TryParseUncPath`) +- Modify: `src/CodeShellManager/Services/GitService.cs` (`TryParseWslUnc` delegates; `TranslateUncArgsToLinux` boundary) +- Modify: `src/CodeShellManager/Views/NewSessionDialog.xaml.cs` (`ParseWslUncPath` delegates) +- Modify: `tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs`, `tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs` + +**Interfaces:** +- Produces: `WslDiscoveryService.TryParseUncPath(string path) : (string? distro, string linuxPath)` — `linuxPath` is `"/"` for the distro root, `""` when not a WSL UNC. Accepts `\\wsl$\` and `\\wsl.localhost\`, either slash direction, case-insensitive prefix. +- `GitService.TryParseWslUnc(path)` becomes `=> WslDiscoveryService.TryParseUncPath(path)`. +- `NewSessionDialog.ParseWslUncPath(unc)` becomes a thin wrapper that maps `null → ""` and `"/" → ""` (dialog convention: blank Linux folder = home). `NewSessionDialogTests` must keep passing unchanged. + +- [ ] **Step 1: Tests** + +Add to `GitServiceWslRoutingTests`: + +```csharp + [Fact] + public void TranslateUncArgsToLinux_PrefixCollidingDistro_LeftAlone() + { + // `Ubuntu` must not match `Ubuntu-22.04` — the default `wsl --install` naming. + string args = "worktree add \"\\\\wsl$\\Ubuntu-22.04\\home\\alice\\x\" main"; + Assert.Equal(args, GitService.TranslateUncArgsToLinux(args, "Ubuntu")); + string bare = "worktree add \\\\wsl$\\Ubuntu-22.04\\home\\alice\\x main"; + Assert.Equal(bare, GitService.TranslateUncArgsToLinux(bare, "Ubuntu")); + } + + [Fact] + public void TranslateUncArgsToLinux_DistroRootUnquoted_BecomesSlash() + { + Assert.Equal("-C / status", GitService.TranslateUncArgsToLinux("-C \\\\wsl$\\Ubuntu status", "Ubuntu")); + } +``` + +Add to `WslDiscoveryServiceTests`: + +```csharp + [Theory] + [InlineData(@"\\wsl$\Ubuntu\home\alice", "Ubuntu", "/home/alice")] + [InlineData(@"\\wsl.localhost\Debian\srv\app", "Debian", "/srv/app")] + [InlineData(@"\\WSL$\Ubuntu\", "Ubuntu", "/")] + [InlineData(@"//wsl$/Ubuntu/home/alice", "Ubuntu", "/home/alice")] + [InlineData(@"\\wsl$\Ubuntu", "Ubuntu", "/")] + [InlineData(@"\\wsl$\", null, "")] + [InlineData(@"C:\proj", null, "")] + [InlineData("", null, "")] + public void TryParseUncPath_KnownShapes(string path, string? distro, string linux) + { + var (d, l) = WslDiscoveryService.TryParseUncPath(path); + Assert.Equal(distro, d); + Assert.Equal(linux, l); + } +``` + +- [ ] **Step 2: Run → the two new GitService tests fail; the discovery tests fail to compile.** + +- [ ] **Step 3: Implement** + +In `WslDiscoveryService.cs` add: + +```csharp + /// + /// Splits a WSL UNC (\\wsl$\Ubuntu\home\alice or \\wsl.localhost\…, either + /// slash direction) into (distro, linuxPath). linuxPath is "/" for the distro root. + /// Returns (null, "") for anything that isn't a WSL UNC. The single parser for the + /// whole app — GitService and NewSessionDialog both delegate here. + /// + public static (string? distro, string linuxPath) TryParseUncPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) return (null, ""); + string normalized = path.Replace('/', '\\').TrimEnd('\\'); + foreach (var prefix in new[] { @"\\wsl$\", @"\\wsl.localhost\" }) + { + 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)..]; + return (distro, string.IsNullOrEmpty(linuxRest) ? "/" : "/" + linuxRest.Replace('\\', '/')); + } + return (null, ""); + } +``` + +In `GitService.cs` replace the body of `TryParseWslUnc` with `=> WslDiscoveryService.TryParseUncPath(path);` (keep the `internal static` signature). In `TranslateUncArgsToLinux` change the `body` line to: + +```csharp + // Lookahead: the distro name must be followed by a separator, a quote, whitespace or + // end-of-string — otherwise `Ubuntu` also matches `Ubuntu-22.04`. + string body = $@"\\\\wsl(?:\$|\.localhost)\\{esc}(?=[\\""\s]|$)"; +``` + +In `NewSessionDialog.xaml.cs` replace the body of `ParseWslUncPath` with: + +```csharp + var (distro, linux) = WslDiscoveryService.TryParseUncPath(unc); + // Dialog convention: blank Linux folder means "the user's home", so the distro root + // ("/") from the shared parser is reported as "" here. + return distro is null ? ("", "") : (distro, linux == "/" ? "" : linux); +``` + +- [ ] **Step 4: Build + full tests → commit** + +```bash +git add -A +git commit -m "fix(wsl): one UNC parser, and a distro-name boundary in the arg translator + +Ubuntu no longer matches Ubuntu-22.04 when translating wsl UNC args for git; +GitService and NewSessionDialog share WslDiscoveryService.TryParseUncPath. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 8: Relaunch-from-search preserves the session kind + +**Why:** `TryRelaunchFromHistoryAsync` recreates a session from `session_history`, which holds seven kind-agnostic columns. A closed WSL (or SSH) session relaunched from a search hit comes back Local at the UNC and runs the Windows `claude`. `RecentlyClosedEntry` already carries everything needed — store it alongside. + +**Files:** +- Modify: `src/CodeShellManager/Services/SearchService.cs` (schema ~line 84-95, `SessionHistoryEntry` record line 22, `RecordSessionHistoryAsync`, both `GetSessionHistory*` readers) +- Modify: `src/CodeShellManager/MainWindow.xaml.cs` (`pty.Exited` handler ~line 1347; `TryRelaunchFromHistoryAsync` ~line 5171) +- Modify: `tests/CodeShellManager.Tests/SearchServiceTests.cs` + +**Interfaces:** +- Produces: column `session_history.snapshot_json TEXT NULL`, added by `InitializeSchemaAsync` via `ALTER TABLE` when `PRAGMA table_info(session_history)` lacks it. +- Produces: `SessionHistoryEntry` gains a trailing `string? SnapshotJson = null` positional parameter. +- Produces: `RecordSessionHistoryAsync(..., string groupId, string? snapshotJson = null)`. + +- [ ] **Step 1: Tests** + +Append to `SearchServiceTests`: + +```csharp + [Fact] + public async Task SessionHistory_RoundTripsSnapshotJson() + { + await _svc.RecordSessionHistoryAsync("sid-1", "n", @"\\wsl$\Ubuntu\home\a", "claude", "", "", "{\"Kind\":2}"); + var e = await _svc.GetSessionHistoryAsync("sid-1"); + Assert.NotNull(e); + Assert.Equal("{\"Kind\":2}", e!.SnapshotJson); + } + + [Fact] + public async Task SessionHistory_WithoutSnapshot_ReadsNull() + { + await _svc.RecordSessionHistoryAsync("sid-2", "n", @"C:\p", "claude", "", ""); + var e = await _svc.GetLatestSessionHistoryForFolderAsync(@"C:\p"); + Assert.Null(e!.SnapshotJson); + } + + [Fact] + public async Task InitializeSchema_UpgradesPreSnapshotTable() + { + // Simulate a database created by the previous release: same table without the column. + string path = Path.Combine(Path.GetTempPath(), $"csm-hist-{Guid.NewGuid():N}.db"); + var db = new SqliteConnection($"Data Source={path}"); + db.Open(); + try + { + await using (var cmd = db.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE session_history ( + id INTEGER PRIMARY KEY, session_id TEXT NOT NULL, session_name TEXT NOT NULL, + working_folder TEXT NOT NULL, command TEXT NOT NULL, args TEXT NOT NULL DEFAULT '', + group_id TEXT NOT NULL DEFAULT '', exited_at INTEGER NOT NULL); + INSERT INTO session_history (session_id, session_name, working_folder, command, exited_at) + VALUES ('old', 'o', 'C:\x', 'bash', 1); + """; + await cmd.ExecuteNonQueryAsync(); + } + await SearchService.InitializeSchemaAsync(db); + await SearchService.InitializeSchemaAsync(db); // idempotent + var svc = new SearchService(db); + var e = await svc.GetSessionHistoryAsync("old"); + Assert.NotNull(e); + Assert.Null(e!.SnapshotJson); + } + finally + { + db.Close(); db.Dispose(); SqliteConnection.ClearAllPools(); + try { File.Delete(path); } catch { } + } + } +``` + +- [ ] **Step 2: Run → compile error (`SnapshotJson`).** + +- [ ] **Step 3: Implement `SearchService`** + +Record: `public record SessionHistoryEntry(string SessionId, string SessionName, string WorkingFolder, string Command, string Args, string GroupId, DateTime ExitedAt, string? SnapshotJson = null);` + +Add `snapshot_json TEXT NULL` to the `CREATE TABLE IF NOT EXISTS session_history` DDL (fresh databases). After the big DDL `ExecuteNonQueryAsync`, add a guarded upgrade for existing databases: + +```csharp + // Column added after the first release of session_history; CREATE TABLE IF NOT + // EXISTS won't touch an existing table, so upgrade explicitly. Idempotent. + bool hasSnapshot = false; + await using (var probe = db.CreateCommand()) + { + probe.CommandText = "PRAGMA table_info(session_history)"; + await using var r = await probe.ExecuteReaderAsync(); + while (await r.ReadAsync()) + if (string.Equals(r.GetString(1), "snapshot_json", StringComparison.OrdinalIgnoreCase)) hasSnapshot = true; + } + if (!hasSnapshot) + { + await using var alter = db.CreateCommand(); + alter.CommandText = "ALTER TABLE session_history ADD COLUMN snapshot_json TEXT NULL"; + await alter.ExecuteNonQueryAsync(); + } +``` + +`RecordSessionHistoryAsync`: add parameter `string? snapshotJson = null`, add `snapshot_json` to the column list and `$snap` to VALUES; `cmd.Parameters.AddWithValue("$snap", (object?)snapshotJson ?? DBNull.Value);`. + +Both readers: select `snapshot_json` as the 8th column and pass `r.IsDBNull(7) ? null : r.GetString(7)`. + +- [ ] **Step 4: Implement `MainWindow`** + +In the `pty.Exited` handler (~line 1347) pass the snapshot: + +```csharp + _ = _searchService.RecordSessionHistoryAsync( + session.Id, session.Name, session.WorkingFolder, + session.Command, session.Args, session.GroupId, + System.Text.Json.JsonSerializer.Serialize(Models.RecentlyClosedEntry.FromSession(session))); +``` + +In `TryRelaunchFromHistoryAsync`, replace the final three lines (`CreateSession … SeedRunCommandsAsync … LaunchSessionAsync`) with: + +```csharp + // Prefer the full snapshot: it carries Kind and the SSH/WSL fields, so a WSL session + // relaunches as WSL instead of Local-at-a-UNC. Rows from before the column exist + // without one and fall back to the kind-agnostic columns. + Models.RecentlyClosedEntry? snapshot = null; + if (!string.IsNullOrEmpty(entry.SnapshotJson)) + { + try { snapshot = System.Text.Json.JsonSerializer.Deserialize(entry.SnapshotJson); } + catch (System.Text.Json.JsonException ex) { Log($"History snapshot unreadable for '{entry.SessionId}': {ex.Message}"); } + } + if (snapshot != null) + { + snapshot.MigrateLegacyFields(); + await ReopenClosedSessionAsync(snapshot); + return; + } + + var newSession = _sessionManager.CreateSession( + entry.SessionName, entry.WorkingFolder, entry.Command, entry.Args, entry.GroupId); + SeedRunCommandsAsync(newSession); + await LaunchSessionAsync(newSession); +``` + +- [ ] **Step 5: Build + full tests → commit** + +```bash +git add -A +git commit -m "fix(search): relaunching a closed session from a search hit keeps its kind + +session_history gains a snapshot_json column holding the RecentlyClosedEntry; +relaunch goes through ReopenClosedSessionAsync so WSL/SSH sessions come back +as themselves. Existing databases are upgraded in InitializeSchemaAsync. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 9: New Session dialog — no re-entrancy during the WSL home probe + +**Why:** `Start_Click` is `async void` and awaits a wsl.exe spawn (up to 3 s) with the default button still enabled. A second Enter runs a second probe and then sets `DialogResult` on a closed window (InvalidOperationException, logged as UNHANDLED). `BrowseWslFolder_Click` can pop its picker after the dialog closed. + +**Files:** +- Modify: `src/CodeShellManager/Views/NewSessionDialog.xaml.cs` (`Start_Click`, `BrowseWslFolder_Click`, constructor) + +- [ ] **Step 1: Add state + Closed hook** + +Fields: `private bool _submitting; private bool _closed;` In the constructor after `InitializeComponent();`: `Closed += (_, _) => _closed = true;` + +- [ ] **Step 2: Guard `Start_Click`** + +Wrap the body: at the top `if (_submitting) return; _submitting = true; OkButton.IsEnabled = false; try { …existing body… } finally { if (!_closed) { _submitting = false; OkButton.IsEnabled = true; } }`. Immediately after the `await WslDiscoveryService.GetDistroHomeAsync(...)` line add `if (_closed) return;`. Every early `return` inside the body (validation failures) now exits through `finally`, which re-enables the button — that is the desired behaviour. + +- [ ] **Step 3: Guard `BrowseWslFolder_Click`** + +After `string seed = await ComputeWslBrowseSeedAsync(...)` add `if (_closed) return;`. + +- [ ] **Step 4: Build + full tests → commit** + +```bash +git add -A +git commit -m "fix(new-session): no double submit while the WSL home probe runs + +Start_Click disables the primary button for the duration and bails out if the +window closed during the await, so a second Enter or a Cancel can't set +DialogResult on a closed window. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 10: Documentation — CLAUDE.md + +**Files:** +- Modify: `CLAUDE.md` (Services table line ~81; "Session Lifecycle" steps 1-2 lines ~154-155; "Editing a Session's Configuration" lines 171-226; add "## WSL Sessions" after "SSH Remote Sessions" (ends ~line 238); "Recently Closed Sessions" ~260; "Per-Session Run Commands" `Mode` bullet ~328; "Color / Theme" accent key ~131) + +- [ ] **Step 1: Services table** — add a row after `ShellIntegrationPayload`: + +``` +| `WslDiscoveryService` | `wsl -l -v` parsing (UTF-16, header skipped, `*` default marker, names with spaces), `GetDistroHomeAsync` (cached `cd ~ && pwd` per distro+user), `ToUncPath` / `TryParseUncPath` — the **only** UNC↔Linux path converters; GitService and NewSessionDialog delegate here | +``` + +- [ ] **Step 2: Session Lifecycle** — step 1 becomes "… `NewSessionDialog` modal (Local, Remote SSH, or WSL)"; step 2 "… caller sets `Kind` and copies SSH or WSL fields; for WSL it also sets `WorkingFolder` to the `\\wsl$\\` UNC mirror (see 'WSL Sessions')". + +- [ ] **Step 3: Editing a Session's Configuration** — in the dialog bullets add "Local/Remote/WSL radio … WSL distro (pre-selected once the async list loads; a since-uninstalled distro is kept as a `(not installed)` entry so Save cannot wipe it), WSL user and Linux folder are all pre-filled. The appearance panel is shown for Local and WSL, hidden only for SSH." In the "Result plumbing" bullets: `Diff` also reports `WorkingFolderChanged` for a WSL distro/Linux-folder change; `Apply(session, draft)` writes `Kind` directly and **re-derives the UNC `WorkingFolder`** for WSL. In "What needs a restart": replace "local↔remote flip" with "any `Kind` change" and add "any WSL field change (distro, user, Linux folder)". + +- [ ] **Step 4: New section after "SSH Remote Sessions"** + +```markdown +## WSL Sessions + +`SessionKind.Wsl` launches `wsl.exe -d [-u ] --cd -- bash -lc ""` (PR #65, hardened on `feat/wsl-sessions-v2`). + +- **`Kind` is the only persisted discriminator.** `IsRemote` is a `[JsonIgnore]`d two-way convenience over `Kind` (`false` on an SSH session makes it Local; a WSL session is untouched). The legacy `"IsRemote"` JSON key lands in `LegacyIsRemote` and `StateService.Normalize` folds it into `Kind` — migration lives in the loader, never in a setter. A promote-only setter was tried first and silently broke "Edit session" (SSH→Local could not demote) and then every save (`FullCommandLine` was serialised and threw); see `ShellSessionMigrationTests`. +- **UNC mirror invariant.** A WSL session stores `WorkingFolder = WslDiscoveryService.ToUncPath(WslDistro, WslWorkingFolder)` (`\\wsl$\Ubuntu\home\alice\proj`). Explorer, the dormant row, run-command template seeding and `GitService` all work off that path unchanged. The Linux-side path lives on `WslWorkingFolder` and is what `--cd` receives. Every path that creates or edits a WSL session must keep both in step: `MainWindow` session creation, `InheritSessionKindFrom` (duplicate / worktree), `SessionConfigEditor.Apply`, `ReopenClosedSessionAsync`. +- **GitService routing.** `RunGitFullAsync` detects the UNC and runs `wsl.exe -d -- git -C …`, translating `\\wsl$` args to Linux (`TranslateUncArgsToLinux`, with a distro-name boundary so `Ubuntu` never matches `Ubuntu-22.04`) and Linux paths in stdout back to UNC. `SessionViewModel.RefreshGitInfoAsync` runs the probe on the thread pool (a `Directory.Exists` on `\\wsl$` boots a stopped distro and used to freeze the UI), polls WSL sessions every **30 s** (10 s local) and caches a "not a repo" answer for WSL so it does not spawn `wsl.exe` for it forever. +- **Quoting.** Everything that reaches `wsl.exe` goes through `ShellSession.QuoteForCmd` — MSVCRT rules (backslashes before a quote doubled, trailing backslashes doubled), verified by round-tripping through `CommandLineToArgvW` in `Win32CommandLineTests`. There is one `BuildWslArgs` (`ShellSession.BuildWslArgs(string? inner)`); `RunInstance` delegates to it and turns a build failure into a failed run chip rather than a throw. +- **Validation before UI.** `ShellSession.LaunchValidationError` (blank distro / blank SSH host) is checked at the top of `LaunchSessionAsync` before any WebView2 exists. `state.json` and imports are untrusted; a bad entry used to leak a pane. +- **Relaunch paths.** `RecentlyClosedEntry` and the `session_history.snapshot_json` column carry `Kind` + WSL fields, so Ctrl+Shift+T, the "Recently closed" list and relaunch-from-search all restore the right kind. +- **Known gaps:** WSL Claude sessions do not auto-resume on restore (`--resume` id lookup reads the Windows `~/.claude`); a distro name beginning with `-` is not defended against in `wsl.exe` option parsing. +``` + +- [ ] **Step 5: Per-Session Run Commands** — change "SSH parents ignore `Mode` — remote runs always go through bash." to "SSH and WSL parents ignore `Mode` — those runs always go through bash (`ssh … bash -c` / `wsl.exe … bash -lc`)." + +- [ ] **Step 6: Color / Theme** — "For local sessions the key is `WorkingFolder`; for SSH sessions `user@host`; for WSL `wsl://`." + +- [ ] **Step 7: Recently Closed Sessions** — add one sentence: "Entries carry `Kind` and the SSH/WSL fields; legacy entries are migrated by `StateService.Normalize` like sessions." + +- [ ] **Step 8: Verify** — `grep -n IsRemote CLAUDE.md`; every remaining mention must describe the convenience property or the legacy key, not a branch point. + +- [ ] **Step 9: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: WSL sessions section; Kind replaces IsRemote throughout CLAUDE.md + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 11: Final gates + +- [ ] **Step 1:** `dotnet build src/CodeShellManager/CodeShellManager.csproj -nologo -c Release 2>&1 | grep -E "warning|error|Build succeeded"` — no new warnings vs. `origin/main`. +- [ ] **Step 2:** `dotnet test tests/CodeShellManager.Tests/ -nologo -v q` — all pass; record the count. +- [ ] **Step 3:** `git log --oneline origin/main..HEAD` — every commit carries the trailer. +- [ ] **Step 4:** Dispatch a read-only review agent against `origin/main...HEAD` with the ten original findings as a checklist; fix anything CONFIRMED, re-run gates. +- [ ] **Step 5:** Push `feat/wsl-sessions-v2`, open the PR against `main` referencing #65 and crediting the contributor; body lists each finding → fix → test. From ba67e8a07e8bbd5d57fa4f7bae9a48fce5a46f3a Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 22:12:10 +0200 Subject: [PATCH 26/45] refactor(sessions): migrate legacy IsRemote in the state loader, not a setter The promote-only setter from #65 ignored false, so SessionConfigEditor.Apply could never flip an SSH session back to Local, and the still-serialised FullCommandLine then threw on every save. Kind is the only persisted field; IsRemote is a [JsonIgnore]d two-way convenience; the legacy JSON key lands in LegacyIsRemote and StateService.Normalize folds it into Kind. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- src/CodeShellManager/MainWindow.xaml.cs | 5 - .../Models/RecentlyClosedEntry.cs | 26 ++-- src/CodeShellManager/Models/ShellSession.cs | 63 +++++++--- src/CodeShellManager/Services/StateService.cs | 4 + .../RecentlyClosedEntryTests.cs | 4 +- .../ShellSessionMigrationTests.cs | 114 +++++++++++------- .../ShellSessionTests.cs | 2 +- 7 files changed, 142 insertions(+), 76 deletions(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index d2aadde..38f69f9 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -745,12 +745,7 @@ private async Task ReopenClosedSessionAsync(RecentlyClosedEntry en string.IsNullOrEmpty(entry.GroupId) ? null : entry.GroupId, colorOverride: entry.ColorOverride); - // 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; diff --git a/src/CodeShellManager/Models/RecentlyClosedEntry.cs b/src/CodeShellManager/Models/RecentlyClosedEntry.cs index 18489e3..4cf1aa2 100644 --- a/src/CodeShellManager/Models/RecentlyClosedEntry.cs +++ b/src/CodeShellManager/Models/RecentlyClosedEntry.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; namespace CodeShellManager.Models; @@ -24,19 +25,26 @@ public class RecentlyClosedEntry public string? ColorOverride { 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. + /// Kind of the closed session, so a reopened WSL or SSH session comes back as the same + /// kind instead of Local at a UNC. Legacy entries carried only IsRemote; see + /// . /// public SessionKind Kind { get; set; } = SessionKind.Local; - public bool IsRemote + [JsonIgnore] + public bool IsRemote => Kind == SessionKind.Ssh; + + /// Legacy "IsRemote" JSON slot — see . + [JsonPropertyName("IsRemote")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? LegacyIsRemote { get; set; } + + public void MigrateLegacyFields() { - get => Kind == SessionKind.Ssh; - set { if (value && Kind == SessionKind.Local) Kind = SessionKind.Ssh; } + if (LegacyIsRemote == true && Kind == SessionKind.Local) Kind = SessionKind.Ssh; + LegacyIsRemote = null; } + public string SshUser { get; set; } = ""; public string SshHost { get; set; } = ""; public int SshPort { get; set; } = 22; @@ -75,7 +83,6 @@ public bool IsRemote GroupId = s.GroupId, ColorOverride = s.ColorOverride, Kind = s.Kind, - IsRemote = s.IsRemote, SshUser = s.SshUser, SshHost = s.SshHost, SshPort = s.SshPort, @@ -106,6 +113,7 @@ public bool IsRemote }; /// Friendly subtitle for the recents UI — kind-specific locator. + [JsonIgnore] public string Subtitle => Kind switch { SessionKind.Ssh => string.IsNullOrWhiteSpace(SshUser) ? SshHost : $"{SshUser}@{SshHost}", diff --git a/src/CodeShellManager/Models/ShellSession.cs b/src/CodeShellManager/Models/ShellSession.cs index f93e5b0..2bc331c 100644 --- a/src/CodeShellManager/Models/ShellSession.cs +++ b/src/CodeShellManager/Models/ShellSession.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Text.Json.Serialization; namespace CodeShellManager.Models; @@ -42,30 +43,54 @@ 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 - /// . + /// Authoritative session kind. Everything that branches on session type reads this. + /// Legacy state.json files (pre-Kind) only carried IsRemote; see + /// and . /// public SessionKind Kind { get; set; } = SessionKind.Local; - // SSH / remote session fields /// - /// 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. + /// Convenience view of for the SSH case. Setting true makes + /// the session SSH; setting false on an SSH session makes it Local. It is + /// deliberately NOT persisted — is — and it carries no migration + /// logic. A WSL session is unaffected by IsRemote = false. /// + [JsonIgnore] public bool IsRemote { get => Kind == SessionKind.Ssh; - set { if (value && Kind == SessionKind.Local) Kind = SessionKind.Ssh; } + set + { + if (value) Kind = SessionKind.Ssh; + else if (Kind == SessionKind.Ssh) Kind = SessionKind.Local; + } + } + + /// + /// Read-only compatibility slot for the pre- "IsRemote" JSON + /// key. Populated only when an old file is deserialised; + /// folds it into and nulls it so it is never written back. + /// + [JsonPropertyName("IsRemote")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? LegacyIsRemote { get; set; } + + /// + /// Folds legacy JSON fields into their current representation. Idempotent. Called by + /// StateService.Normalize for every loaded or imported session — the loader is + /// the one place that knows it is looking at possibly-old data. + /// + public void MigrateLegacyFields() + { + if (LegacyIsRemote == true && Kind == SessionKind.Local) Kind = SessionKind.Ssh; + LegacyIsRemote = null; } /// True iff this session runs inside a WSL distro via wsl.exe. + [JsonIgnore] public bool IsWsl => Kind == SessionKind.Wsl; + + // SSH / remote session fields public string SshUser { get; set; } = ""; public string SshHost { get; set; } = ""; public int SshPort { get; set; } = 22; @@ -103,11 +128,16 @@ public bool IsRemote /// public List RunCommands { get; set; } = new(); - // Full command line for display and passthrough. + /// + /// Full command line for display. Never throws: an incomplete session (blank SSH host, + /// blank WSL distro) shows just the executable — this string is used in error dialogs + /// on exactly those paths. + /// + [JsonIgnore] public string FullCommandLine => Kind switch { - SessionKind.Ssh => $"ssh {BuildSshArgs()}", - SessionKind.Wsl => $"wsl.exe {BuildWslArgs()}", + SessionKind.Ssh => string.IsNullOrWhiteSpace(SshHost) ? "ssh" : $"ssh {BuildSshArgs()}", + SessionKind.Wsl => string.IsNullOrWhiteSpace(WslDistro) ? "wsl.exe" : $"wsl.exe {BuildWslArgs()}", _ => string.IsNullOrWhiteSpace(Args) ? Command : $"{Command} {Args}", }; @@ -179,6 +209,7 @@ internal static string QuoteForCmd(string value) /// Subtitle-line text for the sidebar: a short, kind-appropriate locator. /// Local → working folder leaf; Ssh → host; Wsl → distro:linux-leaf. /// + [JsonIgnore] public string FolderShort => Kind switch { SessionKind.Ssh => string.IsNullOrWhiteSpace(SshHost) ? "" : SshHost, @@ -191,6 +222,7 @@ internal static string QuoteForCmd(string value) /// /// What to show as the session's label when is blank. /// + [JsonIgnore] public string DefaultDisplayName => Kind switch { SessionKind.Ssh => string.IsNullOrWhiteSpace(SshHost) ? Command : SshHost, @@ -207,6 +239,7 @@ internal static string QuoteForCmd(string value) /// siblings share an accent via the repo-root override done in ; /// this is the base key when no repo-root is known. /// + [JsonIgnore] public string AccentKey => Kind switch { SessionKind.Ssh => string.IsNullOrWhiteSpace(SshUser) ? SshHost : $"{SshUser}@{SshHost}", diff --git a/src/CodeShellManager/Services/StateService.cs b/src/CodeShellManager/Services/StateService.cs index a8ebfab..0041f0a 100644 --- a/src/CodeShellManager/Services/StateService.cs +++ b/src/CodeShellManager/Services/StateService.cs @@ -96,6 +96,10 @@ internal static AppState Normalize(AppState s) s.RecentlyClosed ??= []; s.GroupLayouts ??= new(); s.Settings ??= new(); + // Legacy-field migration lives here, in the loader, so the models stay free of + // deserialisation-order tricks. Import goes through this too (ImportExportService). + foreach (var session in s.Sessions) session.MigrateLegacyFields(); + foreach (var entry in s.RecentlyClosed) entry.MigrateLegacyFields(); return s; } diff --git a/tests/CodeShellManager.Tests/RecentlyClosedEntryTests.cs b/tests/CodeShellManager.Tests/RecentlyClosedEntryTests.cs index 8a2cfda..72b15b0 100644 --- a/tests/CodeShellManager.Tests/RecentlyClosedEntryTests.cs +++ b/tests/CodeShellManager.Tests/RecentlyClosedEntryTests.cs @@ -104,7 +104,7 @@ public void Subtitle_RemoteSession_ReturnsUserAtHost() { var e = new RecentlyClosedEntry { - IsRemote = true, + Kind = SessionKind.Ssh, SshUser = "bob", SshHost = "dev.local", WorkingFolder = @"C:\should-be-ignored", @@ -117,7 +117,7 @@ public void Subtitle_RemoteSessionWithoutUser_ReturnsHostOnly() { var e = new RecentlyClosedEntry { - IsRemote = true, + Kind = SessionKind.Ssh, SshHost = "dev.local", }; Assert.Equal("dev.local", e.Subtitle); diff --git a/tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs b/tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs index 8ff1315..cc8c61e 100644 --- a/tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs +++ b/tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs @@ -1,75 +1,104 @@ using System.Text.Json; using CodeShellManager.Models; +using CodeShellManager.Services; 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 . +/// enum and only carried IsRemote. Migration happens in +/// — the loader — not in a property setter, so the +/// in-memory setter can stay a plain two-way switch. /// public class ShellSessionMigrationTests { + private static AppState LoadState(string json) => + StateService.Normalize(JsonSerializer.Deserialize(json)!); + [Fact] - public void Deserialize_LegacyIsRemoteTrue_PromotesKindToSsh() + public void Normalize_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 - } + { "Sessions": [ { "IsRemote": true, "SshUser": "alice", "SshHost": "dev.example.com" } ] } """; - var s = JsonSerializer.Deserialize(legacy)!; + var s = LoadState(legacy).Sessions[0]; Assert.Equal(SessionKind.Ssh, s.Kind); Assert.True(s.IsRemote); - Assert.Equal("alice", s.SshUser); + Assert.Null(s.LegacyIsRemote); } [Fact] - public void Deserialize_LegacyIsRemoteFalse_KeepsKindLocal() + public void Normalize_LegacyIsRemoteFalse_KeepsKindLocal() { - const string legacy = """{ "IsRemote": false, "WorkingFolder": "C:\\proj" }"""; - var s = JsonSerializer.Deserialize(legacy)!; + const string legacy = """{ "Sessions": [ { "IsRemote": false, "WorkingFolder": "C:\\proj" } ] }"""; + var s = LoadState(legacy).Sessions[0]; Assert.Equal(SessionKind.Local, s.Kind); Assert.False(s.IsRemote); } [Fact] - public void Deserialize_NewFormatWithKindWsl_LeavesIsRemoteFalse() + public void Normalize_KindWslWithStrayLegacyFalse_StaysWsl() { - // 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)!; + const string mixed = """{ "Sessions": [ { "Kind": 2, "IsRemote": false, "WslDistro": "Ubuntu" } ] }"""; + var s = LoadState(mixed).Sessions[0]; Assert.Equal(SessionKind.Wsl, s.Kind); - Assert.False(s.IsRemote); - Assert.Equal("Ubuntu", s.WslDistro); } [Fact] - public void Deserialize_BothKindAndLegacyIsRemote_KindWinsWhenKindIsWsl() + public void Normalize_KindWslWithStrayLegacyTrue_KindWins() { - // 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)!; + // Kind is authoritative once present; a stale IsRemote must not clobber it. + const string mixed = """{ "Sessions": [ { "Kind": 2, "IsRemote": true, "WslDistro": "Ubuntu" } ] }"""; + var s = LoadState(mixed).Sessions[0]; + Assert.Equal(SessionKind.Wsl, s.Kind); + } + + [Fact] + public void Normalize_RecentlyClosedLegacyIsRemote_PromotesToSsh() + { + const string legacy = """{ "RecentlyClosed": [ { "IsRemote": true, "SshHost": "h" } ] }"""; + var e = LoadState(legacy).RecentlyClosed[0]; + Assert.Equal(SessionKind.Ssh, e.Kind); + Assert.Null(e.LegacyIsRemote); + } + + [Fact] + public void Serialize_DoesNotWriteLegacyIsRemoteOrComputedProperties() + { + var s = new ShellSession { Kind = SessionKind.Ssh, SshHost = "h" }; + string json = JsonSerializer.Serialize(s); + Assert.DoesNotContain("\"IsRemote\"", json); + Assert.DoesNotContain("FullCommandLine", json); + Assert.DoesNotContain("FolderShort", json); + Assert.DoesNotContain("AccentKey", json); + Assert.Contains("\"Kind\":1", json); + } + + [Fact] + public void Serialize_IncompleteSshSession_DoesNotThrow() + { + // Regression: FullCommandLine used to be serialised and BuildSshArgs threw on a + // blank host, which made every state.json save fail after a bad edit. + var s = new ShellSession { Kind = SessionKind.Ssh, SshHost = "" }; + string json = JsonSerializer.Serialize(s); + Assert.NotNull(json); + Assert.Equal("ssh", s.FullCommandLine); + } + + [Fact] + public void IsRemoteSetter_FalseOnSsh_DemotesToLocal() + { + var s = new ShellSession { Kind = SessionKind.Ssh }; + s.IsRemote = false; + Assert.Equal(SessionKind.Local, s.Kind); + } + + [Fact] + public void IsRemoteSetter_FalseOnWsl_LeavesWsl() + { + var s = new ShellSession { Kind = SessionKind.Wsl }; + s.IsRemote = false; Assert.Equal(SessionKind.Wsl, s.Kind); } @@ -78,10 +107,7 @@ public void Roundtrip_NewFormat_PreservesKind() { var original = new ShellSession { - Kind = SessionKind.Wsl, - WslDistro = "Debian", - WslUser = "bob", - WslWorkingFolder = "/srv/app", + Kind = SessionKind.Wsl, WslDistro = "Debian", WslUser = "bob", WslWorkingFolder = "/srv/app", }; string json = JsonSerializer.Serialize(original); var revived = JsonSerializer.Deserialize(json)!; diff --git a/tests/CodeShellManager.Tests/ShellSessionTests.cs b/tests/CodeShellManager.Tests/ShellSessionTests.cs index 39bb35c..11a28e3 100644 --- a/tests/CodeShellManager.Tests/ShellSessionTests.cs +++ b/tests/CodeShellManager.Tests/ShellSessionTests.cs @@ -92,7 +92,7 @@ public void BuildSshArgs_EmptyHost_ThrowsInvalidOperationException() } [Fact] - public void IsRemote_SetTrue_PromotesKindToSsh() + public void IsRemote_SetTrue_SetsKindSsh() { var s = new ShellSession { IsRemote = true }; Assert.Equal(SessionKind.Ssh, s.Kind); From 75ba1208849a126fb80c0c358be2de2f20a6832b Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 22:20:30 +0200 Subject: [PATCH 27/45] feat(edit-session): carry Kind and WSL fields through SessionConfigDraft Diff treats distro/user/Linux-folder like the SSH target fields; Apply sets Kind directly and re-derives the UNC WorkingFolder so it can't drift from WslWorkingFolder. Adds the SSH->Local regression test. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- .../Models/SessionConfigDraft.cs | 13 ++- .../Services/SessionConfigEditor.cs | 41 +++++-- .../Views/NewSessionDialog.xaml.cs | 5 +- .../SessionConfigEditorTests.cs | 110 +++++++++++++++++- 4 files changed, 150 insertions(+), 19 deletions(-) diff --git a/src/CodeShellManager/Models/SessionConfigDraft.cs b/src/CodeShellManager/Models/SessionConfigDraft.cs index 88ae9bf..e556b47 100644 --- a/src/CodeShellManager/Models/SessionConfigDraft.cs +++ b/src/CodeShellManager/Models/SessionConfigDraft.cs @@ -20,12 +20,16 @@ public sealed class SessionConfigDraft public string Command { get; set; } = ""; public string Args { get; set; } = ""; - // Remote - public bool IsRemote { get; set; } + // Kind + kind-specific fields. Kind is authoritative; IsRemote is a read-only view. + public SessionKind Kind { get; set; } = SessionKind.Local; + public bool IsRemote => 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; } = ""; // Appearance overrides — null means "use the global terminal settings" public string? ProfileFontFamily { get; set; } @@ -46,7 +50,10 @@ public sealed class SessionConfigDraft WorkingFolder = s.WorkingFolder, Command = s.Command, Args = s.Args, - IsRemote = s.IsRemote, + Kind = s.Kind, + WslDistro = s.WslDistro, + WslUser = s.WslUser, + WslWorkingFolder = s.WslWorkingFolder, SshUser = s.SshUser, SshHost = s.SshHost, SshPort = s.SshPort, diff --git a/src/CodeShellManager/Services/SessionConfigEditor.cs b/src/CodeShellManager/Services/SessionConfigEditor.cs index de6f7a8..3ec05d3 100644 --- a/src/CodeShellManager/Services/SessionConfigEditor.cs +++ b/src/CodeShellManager/Services/SessionConfigEditor.cs @@ -12,7 +12,7 @@ namespace CodeShellManager.Services; /// True when the change can only take effect by tearing down and restarting the PTY — /// see for the exact rules. /// -/// True when the local working folder moved (git info must be re-resolved). +/// True when the local folder or WSL distro/Linux folder moved (git info must be re-resolved). /// True when any per-session appearance override differs. public readonly record struct SessionConfigChange( bool AnyChange, @@ -29,19 +29,26 @@ public static class SessionConfigEditor { public static SessionConfigChange Diff(ShellSession s, SessionConfigDraft d) { - bool modeChanged = d.IsRemote != s.IsRemote; + bool modeChanged = d.Kind != s.Kind; - // Only meaningful while the session stays local — a mode flip already forces a - // relaunch, and stale ssh/folder leftovers from the other mode shouldn't count. - bool folderChanged = !d.IsRemote && !s.IsRemote + // Kind-specific fields only count while the kind is unchanged — a kind flip already + // forces a relaunch, and leftovers from a previous kind must not read as edits. + bool sameKind = !modeChanged; + bool folderChanged = sameKind && s.Kind == SessionKind.Local && !PathsEqual(d.WorkingFolder, s.WorkingFolder); - bool sshChanged = d.IsRemote && s.IsRemote + bool sshChanged = sameKind && s.Kind == SessionKind.Ssh && (!Eq(d.SshUser, s.SshUser) || !Eq(d.SshHost, s.SshHost) || d.SshPort != s.SshPort || !Eq(d.SshRemoteFolder, s.SshRemoteFolder)); + bool wslFolderChanged = sameKind && s.Kind == SessionKind.Wsl + && (!Eq(d.WslDistro, s.WslDistro) + || !LinuxPathsEqual(d.WslWorkingFolder, s.WslWorkingFolder)); + bool wslChanged = wslFolderChanged + || (sameKind && s.Kind == SessionKind.Wsl && !Eq(d.WslUser, s.WslUser)); + bool launchChanged = !Eq(d.Command, s.Command) || !Eq(d.Args, s.Args); bool appearanceChanged = @@ -73,13 +80,13 @@ public static SessionConfigChange Diff(ShellSession s, SessionConfigDraft d) || Cleared(d.ProfileRetroEffect, s.ProfileRetroEffect) || Cleared(d.ProfileColorSchemeJson, s.ProfileColorSchemeJson); - bool anyChange = modeChanged || folderChanged || sshChanged || launchChanged + bool anyChange = modeChanged || folderChanged || sshChanged || wslChanged || launchChanged || appearanceChanged || !Eq(d.Name, s.Name); - bool requiresRelaunch = modeChanged || folderChanged || sshChanged || launchChanged + bool requiresRelaunch = modeChanged || folderChanged || sshChanged || wslChanged || launchChanged || transparencyChanged || overridesCleared; - return new SessionConfigChange(anyChange, requiresRelaunch, folderChanged, appearanceChanged); + return new SessionConfigChange(anyChange, requiresRelaunch, folderChanged || wslFolderChanged, appearanceChanged); } /// @@ -92,12 +99,20 @@ public static void Apply(ShellSession s, SessionConfigDraft d) s.Name = d.Name; s.Command = d.Command; s.Args = d.Args; - s.IsRemote = d.IsRemote; - s.WorkingFolder = d.WorkingFolder; + s.Kind = d.Kind; s.SshUser = d.SshUser; s.SshHost = d.SshHost; s.SshPort = d.SshPort; s.SshRemoteFolder = d.SshRemoteFolder; + s.WslDistro = d.WslDistro; + s.WslUser = d.WslUser; + s.WslWorkingFolder = d.WslWorkingFolder.Trim(); + // WSL sessions keep WorkingFolder as the \\wsl$ UNC mirror of the Linux path so + // Explorer, git polling and the sidebar need no special-casing (see CLAUDE.md + // "WSL Sessions"). Derive it here so the two can never drift apart. + s.WorkingFolder = d.Kind == SessionKind.Wsl + ? WslDiscoveryService.ToUncPath(d.WslDistro, s.WslWorkingFolder) + : d.WorkingFolder; s.ProfileFontFamily = d.ProfileFontFamily; s.ProfileFontSize = d.ProfileFontSize; @@ -118,6 +133,10 @@ private static bool Eq(string? a, string? b) => private static bool Cleared(string? now, string? before) => string.IsNullOrEmpty(now) && !string.IsNullOrEmpty(before); + /// Linux path compare: exact, trailing-slash tolerant, case-sensitive (ext4 is). + internal static bool LinuxPathsEqual(string a, string b) => + string.Equals((a ?? "").Trim().TrimEnd('/'), (b ?? "").Trim().TrimEnd('/'), StringComparison.Ordinal); + /// Case-insensitive path compare that tolerates trailing slashes and bad input. internal static bool PathsEqual(string a, string b) { diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index b5569b4..cc0cbf0 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -319,7 +319,10 @@ private void CopyOverridesFrom(ShellSession s) WorkingFolder = SelectedFolder, Command = SelectedCommand, Args = SelectedArgs, - IsRemote = IsRemote, + Kind = IsWsl ? SessionKind.Wsl : IsRemote ? SessionKind.Ssh : SessionKind.Local, + WslDistro = WslDistro, + WslUser = WslUser, + WslWorkingFolder = WslWorkingFolder, SshUser = SshUser, SshHost = SshHost, SshPort = SshPort, diff --git a/tests/CodeShellManager.Tests/SessionConfigEditorTests.cs b/tests/CodeShellManager.Tests/SessionConfigEditorTests.cs index 855f94e..2c696ea 100644 --- a/tests/CodeShellManager.Tests/SessionConfigEditorTests.cs +++ b/tests/CodeShellManager.Tests/SessionConfigEditorTests.cs @@ -17,7 +17,7 @@ public class SessionConfigEditorTests private static ShellSession RemoteSession() => new() { Name = "dev box", - IsRemote = true, + Kind = SessionKind.Ssh, SshUser = "alice", SshHost = "dev.example.com", SshPort = 22, @@ -25,6 +25,17 @@ public class SessionConfigEditorTests Command = "bash", }; + private static ShellSession WslSession() => new() + { + Name = "ubuntu proj", + Kind = SessionKind.Wsl, + WslDistro = "Ubuntu", + WslUser = "alice", + WslWorkingFolder = "/home/alice/proj", + WorkingFolder = @"\\wsl$\Ubuntu\home\alice\proj", + Command = "claude", + }; + [Fact] public void Diff_IdenticalDraft_ReportsNoChange() { @@ -95,7 +106,7 @@ public void Diff_SwitchingLocalToRemote_RequiresRelaunch() { var s = LocalSession(); var d = SessionConfigDraft.FromSession(s); - d.IsRemote = true; + d.Kind = SessionKind.Ssh; d.SshHost = "dev.example.com"; d.WorkingFolder = ""; @@ -218,7 +229,7 @@ public void Apply_WritesEveryFormFieldAndLeavesRuntimeStateAlone() WorkingFolder = @"C:\src\api", Command = "codex", Args = "--verbose", - IsRemote = false, + Kind = SessionKind.Local, ProfileFontFamily = "Cascadia Code", ProfileFontSize = 15, ProfileCursorShape = "bar", @@ -273,7 +284,7 @@ public void Apply_SwitchingToRemote_UsesTheSshTargetAndDropsTheLocalFolder() { var s = LocalSession(); var d = SessionConfigDraft.FromSession(s); - d.IsRemote = true; + d.Kind = SessionKind.Ssh; d.WorkingFolder = ""; d.SshUser = "alice"; d.SshHost = "dev.example.com"; @@ -288,4 +299,95 @@ public void Apply_SwitchingToRemote_UsesTheSshTargetAndDropsTheLocalFolder() Assert.Equal("", s.WorkingFolder); Assert.Equal("-p 2222 -t alice@dev.example.com \"cd '/srv/app' && bash\"", s.BuildSshArgs()); } + + [Fact] + public void Apply_SshToLocal_DemotesKind() + { + // Regression: with the promote-only IsRemote setter this silently left Kind=Ssh. + var s = RemoteSession(); + var d = SessionConfigDraft.FromSession(s); + d.Kind = SessionKind.Local; + d.WorkingFolder = @"C:\src"; + + Assert.True(SessionConfigEditor.Diff(s, d).RequiresRelaunch); + SessionConfigEditor.Apply(s, d); + + Assert.Equal(SessionKind.Local, s.Kind); + Assert.False(s.IsRemote); + Assert.Equal(@"C:\src", s.WorkingFolder); + } + + [Fact] + public void Diff_WslIdentical_NoChange() + { + var s = WslSession(); + Assert.False(SessionConfigEditor.Diff(s, SessionConfigDraft.FromSession(s)).AnyChange); + } + + [Fact] + public void Diff_WslDistroChanged_RequiresRelaunchAndFolderChanged() + { + var s = WslSession(); + var d = SessionConfigDraft.FromSession(s); + d.WslDistro = "Debian"; + var c = SessionConfigEditor.Diff(s, d); + Assert.True(c.AnyChange); + Assert.True(c.RequiresRelaunch); + Assert.True(c.WorkingFolderChanged); + } + + [Fact] + public void Diff_WslUserChanged_RequiresRelaunchButFolderUnchanged() + { + var s = WslSession(); + var d = SessionConfigDraft.FromSession(s); + d.WslUser = "root"; + var c = SessionConfigEditor.Diff(s, d); + Assert.True(c.RequiresRelaunch); + Assert.False(c.WorkingFolderChanged); + } + + [Fact] + public void Diff_WslLinuxFolderTrailingSlash_IsNotAChange() + { + var s = WslSession(); + var d = SessionConfigDraft.FromSession(s); + d.WslWorkingFolder = "/home/alice/proj/"; + Assert.False(SessionConfigEditor.Diff(s, d).AnyChange); + } + + [Fact] + public void Apply_WslFolderChanged_ResyncsUncWorkingFolder() + { + var s = WslSession(); + var d = SessionConfigDraft.FromSession(s); + d.WslWorkingFolder = "/srv/other"; + SessionConfigEditor.Apply(s, d); + Assert.Equal("/srv/other", s.WslWorkingFolder); + Assert.Equal(@"\\wsl$\Ubuntu\srv\other", s.WorkingFolder); + } + + [Fact] + public void Apply_LocalToWsl_SetsKindAndUnc() + { + var s = LocalSession(); + var d = SessionConfigDraft.FromSession(s); + d.Kind = SessionKind.Wsl; + d.WslDistro = "Ubuntu"; + d.WslWorkingFolder = "/home/alice"; + Assert.True(SessionConfigEditor.Diff(s, d).RequiresRelaunch); + SessionConfigEditor.Apply(s, d); + Assert.Equal(SessionKind.Wsl, s.Kind); + Assert.Equal(@"\\wsl$\Ubuntu\home\alice", s.WorkingFolder); + } + + [Fact] + public void Diff_StaleWslFieldsOnLocalSession_DoNotCount() + { + var s = LocalSession(); + s.WslDistro = "leftover"; + var d = SessionConfigDraft.FromSession(s); + d.WslDistro = ""; + Assert.False(SessionConfigEditor.Diff(s, d).AnyChange); + } } From fb04ff0953f97f0300d8ffdb6bc306f4e90b7ed9 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 22:27:16 +0200 Subject: [PATCH 28/45] feat(edit-session): edit WSL sessions in the shared New Session form ForEdit pre-selects the distro and fills user/Linux folder, the distro list is populated in edit mode, and the appearance panel is available for WSL sessions (only SSH hides it). Boot label names the distro. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- src/CodeShellManager/MainWindow.xaml.cs | 13 ++-- .../Views/NewSessionDialog.xaml.cs | 74 +++++++++++++------ 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 38f69f9..311ec78 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -1322,9 +1322,12 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal string htmlFile = wantTransparent ? "terminal-transparent.html" : "terminal.html"; string htmlPath = new Uri(Path.Combine(assetsDir, htmlFile)).AbsoluteUri; - string bootLabel = session.IsRemote - ? $"Connecting to {session.SshHost}…" - : $"Starting {(string.IsNullOrWhiteSpace(session.Command) ? "session" : session.Command)}…"; + string bootLabel = session.Kind switch + { + Models.SessionKind.Ssh => $"Connecting to {session.SshHost}…", + Models.SessionKind.Wsl => $"Starting {session.WslDistro}…", + _ => $"Starting {(string.IsNullOrWhiteSpace(session.Command) ? "session" : session.Command)}…", + }; bridge.SetBootContext(bootLabel, GetAccentForSession(session)); await bridge.InitializeAsync(htmlPath); bridge.ApplyFontSettings(_vm.Settings); @@ -4566,7 +4569,7 @@ private async Task EditSessionAsync(SessionViewModel vm) var change = Services.SessionConfigEditor.Diff(session, draft); if (!change.AnyChange) return; - bool wasRemote = session.IsRemote; + var wasKind = session.Kind; // Apply mutates session.Command in place, so capture what the RUNNING process was // launched with before that happens. RestartSessionAsync needs the OLD command to // decide whether the outgoing process is a Claude that has to be waited out — @@ -4576,7 +4579,7 @@ private async Task EditSessionAsync(SessionViewModel vm) Services.SessionConfigEditor.Apply(session, draft); vm.NotifyConfigChanged(); - if (change.WorkingFolderChanged || session.IsRemote != wasRemote) + if (change.WorkingFolderChanged || session.Kind != wasKind) _ = vm.ReloadGitInfoAsync(); // No-op when the session carries no overrides; re-asserting the global font first diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index cc0cbf0..d2cd6ed 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -83,7 +83,7 @@ public partial class NewSessionDialog : Window /// 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 = ""; + private string _preselectWslDistro = ""; public NewSessionDialog( string defaultFolder = "", @@ -170,11 +170,13 @@ public NewSessionDialog( Loaded += async (_, _) => { + // The distro list is needed in every mode the WSL radio can be reached from, + // including edit mode — otherwise editing a WSL session shows an empty combo. + await PopulateWslDistrosAsync(); // Sibling-worktree fan-out only makes sense when creating sessions. if (IsEditMode) return; if (IsLocalMode && !string.IsNullOrWhiteSpace(FolderBox.Text)) await ProbeSiblingWorktreesAsync(FolderBox.Text.Trim()); - await PopulateWslDistrosAsync(); }; } @@ -187,10 +189,10 @@ private async System.Threading.Tasks.Task PopulateWslDistrosAsync() { var distros = await WslDiscoveryService.GetDistrosAsync(); WslDistroCombo.Items.Clear(); - if (distros.Count == 0) + bool listWasEmpty = distros.Count == 0; + if (listWasEmpty) { 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) @@ -204,8 +206,27 @@ private async System.Threading.Tasks.Task PopulateWslDistrosAsync() preselectMatch = item; } } - WslDistroCombo.SelectedItem = preselectMatch ?? WslDistroCombo.Items[0]; - WslHelpText.Text = ""; + if (preselectMatch == null && !string.IsNullOrEmpty(_preselectWslDistro)) + { + // Editing a session whose distro is no longer installed (or WSL itself isn't) — + // keep it selectable rather than silently falling back to whatever sorts first, + // which would wipe the distro on Save. + preselectMatch = new ComboBoxItem + { + Content = $"{_preselectWslDistro} (not installed)", + Tag = _preselectWslDistro + }; + WslDistroCombo.Items.Add(preselectMatch); + } + if (preselectMatch != null) + { + WslDistroCombo.SelectedItem = preselectMatch; + } + else if (WslDistroCombo.Items.Count > 0) + { + WslDistroCombo.SelectedItem = WslDistroCombo.Items[0]; + } + if (!listWasEmpty) WslHelpText.Text = ""; } @@ -218,7 +239,7 @@ public static NewSessionDialog ForEdit( IEnumerable? launchCommands = null, IReadOnlyList? profiles = null) => new( - defaultFolder: session.IsRemote ? "" : session.WorkingFolder, + defaultFolder: session.Kind == SessionKind.Local ? session.WorkingFolder : "", launchCommands: launchCommands, profiles: profiles, defaultCommand: session.Command, @@ -241,17 +262,27 @@ private void ApplyEditMode(ShellSession s) RecentlyClosedPanel.Visibility = Visibility.Collapsed; WorktreesPanel.Visibility = Visibility.Collapsed; - if (s.IsRemote) - { - // Checking the radio runs SessionType_Changed, which swaps the panels. It no - // longer blanks NameBox — that handler returns early in edit mode — so the - // assignment below is the only thing setting the name, not a repair. - RemoteRadio.IsChecked = true; - SshHostBox.Text = string.IsNullOrWhiteSpace(s.SshUser) - ? s.SshHost - : $"{s.SshUser}@{s.SshHost}"; - SshPortBox.Text = s.SshPort.ToString(); - SshRemoteFolderBox.Text = s.SshRemoteFolder; + switch (s.Kind) + { + case SessionKind.Ssh: + // Checking the radio runs SessionType_Changed, which swaps the panels. It no + // longer blanks NameBox — that handler returns early in edit mode — so the + // assignment below is the only thing setting the name, not a repair. + RemoteRadio.IsChecked = true; + SshHostBox.Text = string.IsNullOrWhiteSpace(s.SshUser) + ? s.SshHost + : $"{s.SshUser}@{s.SshHost}"; + SshPortBox.Text = s.SshPort.ToString(); + SshRemoteFolderBox.Text = s.SshRemoteFolder; + break; + case SessionKind.Wsl: + WslRadio.IsChecked = true; + WslUserBox.Text = s.WslUser; + WslWorkingFolderBox.Text = s.WslWorkingFolder; + // The distro combo is filled asynchronously on Loaded; PopulateWslDistrosAsync + // selects this name once the list arrives. + _preselectWslDistro = s.WslDistro; + break; } NameBox.Text = s.Name; @@ -283,7 +314,7 @@ private void SetUpEditModeProfileCombo(ShellSession s) Tag = KeepCurrentAppearanceTag }); ProfileLabel.Text = "Appearance"; - ProfilePanel.Visibility = s.IsRemote ? Visibility.Collapsed : Visibility.Visible; + ProfilePanel.Visibility = s.Kind == SessionKind.Ssh ? Visibility.Collapsed : Visibility.Visible; ProfileCombo.SelectedIndex = 0; } @@ -459,9 +490,10 @@ private void SessionType_Changed(object sender, RoutedEventArgs e) 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 + // Appearance overrides apply to any xterm-hosted session, WSL included; SSH is + // excluded because the remote profile is out of our hands. if (ProfilePanel != null && ProfileCombo.Items.Count > 0) - ProfilePanel.Visibility = IsLocalMode ? Visibility.Visible : Visibility.Collapsed; + ProfilePanel.Visibility = IsRemoteMode ? Visibility.Collapsed : Visibility.Visible; if (WorktreesPanel != null) { WorktreesPanel.Visibility = Visibility.Collapsed; From 9856cb4d00072206d31d4a2328ea205d6f6478d7 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 22:34:09 +0200 Subject: [PATCH 29/45] fix(new-session): keep the "(not installed)" distro fallback edit-mode only Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- src/CodeShellManager/Views/NewSessionDialog.xaml.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index d2cd6ed..ae6bc94 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -206,11 +206,16 @@ private async System.Threading.Tasks.Task PopulateWslDistrosAsync() preselectMatch = item; } } - if (preselectMatch == null && !string.IsNullOrEmpty(_preselectWslDistro)) + if (IsEditMode && preselectMatch == null && !string.IsNullOrEmpty(_preselectWslDistro)) { // Editing a session whose distro is no longer installed (or WSL itself isn't) — // keep it selectable rather than silently falling back to whatever sorts first, - // which would wipe the distro on Save. + // which would wipe the distro on Save. Edit-mode-only: in create mode + // _preselectWslDistro is just a suggestion (e.g. "New session here" copying a + // parent's distro), so an unmatched name there should fall back to the first + // installed distro, not manufacture a brand-new session targeting one that + // doesn't exist (Start_Click's blank-distro validation would miss it, since the + // synthetic item's Tag is non-empty). preselectMatch = new ComboBoxItem { Content = $"{_preselectWslDistro} (not installed)", From 0501e46d2f714e1b1e4806af1dde1f232aec8131 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 22:39:57 +0200 Subject: [PATCH 30/45] fix(wsl): MSVCRT-correct quoting, one BuildWslArgs for sessions and runs QuoteForCmd now doubles backslashes before quotes and at the end of a quoted value; verified by round-tripping through CommandLineToArgvW. RunInstance delegates to ShellSession.BuildWslArgs and reports a start failure in the run output instead of throwing from the toolbar click. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- src/CodeShellManager/Models/ShellSession.cs | 76 ++++++---- src/CodeShellManager/Services/RunInstance.cs | 134 ++++++++++-------- .../Win32CommandLineTests.cs | 88 ++++++++++++ 3 files changed, 212 insertions(+), 86 deletions(-) create mode 100644 tests/CodeShellManager.Tests/Win32CommandLineTests.cs diff --git a/src/CodeShellManager/Models/ShellSession.cs b/src/CodeShellManager/Models/ShellSession.cs index 2bc331c..5e7ea3f 100644 --- a/src/CodeShellManager/Models/ShellSession.cs +++ b/src/CodeShellManager/Models/ShellSession.cs @@ -167,40 +167,64 @@ 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. Distro, - /// user, and working-folder values are passed through - /// so values containing spaces (Linux paths often do) survive Win32 arg parsing. + /// Win32 (MSVCRT / CommandLineToArgvW) argument quoting. Space-free, quote-free values + /// are returned unchanged unless is set. Inside quotes, a + /// run of n backslashes followed by " becomes 2n+1 backslashes + quote, and a + /// trailing run of n backslashes becomes 2n so it cannot eat the closing quote. + /// Every value that reaches wsl.exe goes through here — no ad-hoc Replace. /// - internal string BuildWslArgs() + internal static string QuoteForCmd(string value, bool force = false) { - if (string.IsNullOrWhiteSpace(WslDistro)) - throw new InvalidOperationException("WslDistro must be set for WSL sessions."); - var sb = new StringBuilder(); - sb.Append($"-d {QuoteForCmd(WslDistro)}"); - if (!string.IsNullOrWhiteSpace(WslUser)) - sb.Append($" -u {QuoteForCmd(WslUser)}"); - if (!string.IsNullOrWhiteSpace(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("\"", "\\\"")}\""); + value ??= ""; + if (!force && value.Length > 0 && value.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) + return value; + + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + int backslashes = 0; + foreach (char c in value) + { + if (c == '\\') { backslashes++; continue; } + if (c == '"') + { + sb.Append('\\', backslashes * 2 + 1).Append('"'); + backslashes = 0; + continue; + } + sb.Append('\\', backslashes).Append(c); + backslashes = 0; + } + sb.Append('\\', backslashes * 2); + sb.Append('"'); 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. + /// Builds the argument string passed to wsl.exe: + /// -d <distro> [-u <user>] [--cd <linux-folder>] -- bash -lc "<payload>". + /// The payload is + , or + /// when given (run commands). It is wrapped in bash -lc so PATH-resolved tools + /// (nvm node, pyenv, …) behave as in a login shell; bash then interprets the payload as + /// a shell command line, which is the intent. Throws when is + /// blank — callers validate first (LaunchValidationError, Task 5). /// - internal static string QuoteForCmd(string value) + internal string BuildWslArgs(string? inner = null) { - if (string.IsNullOrEmpty(value)) return "\"\""; - if (value.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) return value; - return "\"" + value.Replace("\"", "\\\"") + "\""; + if (string.IsNullOrWhiteSpace(WslDistro)) + throw new InvalidOperationException("WslDistro must be set for WSL sessions."); + var sb = new StringBuilder(); + sb.Append("-d ").Append(QuoteForCmd(WslDistro)); + if (!string.IsNullOrWhiteSpace(WslUser)) + sb.Append(" -u ").Append(QuoteForCmd(WslUser)); + if (!string.IsNullOrWhiteSpace(WslWorkingFolder)) + sb.Append(" --cd ").Append(QuoteForCmd(WslWorkingFolder)); + if (inner is null) + { + var shell = string.IsNullOrWhiteSpace(Command) ? "bash" : Command; + inner = string.IsNullOrWhiteSpace(Args) ? shell : $"{shell} {Args}"; + } + sb.Append(" -- bash -lc ").Append(QuoteForCmd(inner, force: true)); + return sb.ToString(); } // ── Display helpers (single source of truth — see MainWindow sidebar / VM) ──── diff --git a/src/CodeShellManager/Services/RunInstance.cs b/src/CodeShellManager/Services/RunInstance.cs index b0f3950..8820b2d 100644 --- a/src/CodeShellManager/Services/RunInstance.cs +++ b/src/CodeShellManager/Services/RunInstance.cs @@ -90,40 +90,78 @@ public void Start(ShellSession parent) _pty.DataReceived += OnPtyData; _pty.Exited += OnPtyExited; - string command, args, workDir; - switch (parent.Kind) + try { - 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; + string command, args, workDir; + switch (parent.Kind) + { + 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); + } + catch (Exception ex) + { + // A run that can't even build its command line (e.g. blank WslDistro) must + // show as a failed chip, not throw out of the toolbar click. + AppendText($"Cannot start: {ex.Message}\r\n"); + ExitCode = -1; + EndedAt = DateTime.Now; + State = RunState.ExitedFailed; + StateChanged?.Invoke(); + _pty.DataReceived -= OnPtyData; + _pty.Exited -= OnPtyExited; + _pty.Dispose(); + _pty = null; } + } - _pty.Start(command, args, workDir, cols: 200, rows: 50, useJobObject: true); + /// + /// Appends text to the ANSI-stripped output buffer under , + /// refreshes , and raises . + /// + private void AppendText(string text) + { + string snapshot; + lock (_bufLock) + { + _ansiStripped.Append(text); + if (_ansiStripped.Length > MaxBufferChars) + _ansiStripped.Remove(0, _ansiStripped.Length - MaxBufferChars); + snapshot = _ansiStripped.ToString(); + } + OutputBuffer = snapshot; + // Marshal to UI thread is the consumer's responsibility — OutputChanged + // fires from the PTY read loop's thread (or, for the start-failure path, + // synchronously from Start). + OutputChanged?.Invoke(); } public void Stop() @@ -136,16 +174,9 @@ private void OnPtyData(string text) { // Strip ANSI for the readonly drawer view + clipboard. Match the // OutputIndexer regex so any visible quirks stay consistent across the app. - string stripped = AnsiPattern().Replace(text, ""); - lock (_bufLock) - { - _ansiStripped.Append(stripped); - if (_ansiStripped.Length > MaxBufferChars) - _ansiStripped.Remove(0, _ansiStripped.Length - MaxBufferChars); - } // Marshal to UI thread is the consumer's responsibility — OutputChanged // fires from the PTY read loop's thread. - OutputChanged?.Invoke(); + AppendText(AnsiPattern().Replace(text, "")); } private void OnPtyExited() @@ -256,29 +287,12 @@ internal static string BuildSshArgs(ShellSession parent, string commandLine) } /// - /// Builds wsl.exe args for a run executed inside the parent's WSL distro. Pattern: - /// -d <distro> [-u <user>] [--cd <folder>] -- bash -lc '<escaped>' + /// wsl.exe args for a run inside the parent's distro. One implementation with the + /// session launcher — see — so the two can't + /// disagree about quoting or about a blank distro. /// internal static string BuildWslArgs(ShellSession parent, string commandLine) - { - var sb = new StringBuilder(); - 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 {ShellSession.QuoteForCmd(parent.WslWorkingFolder)}"); - // 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(); - } + => parent.BuildWslArgs(commandLine); /// /// POSIX single-quote escape: wraps in single quotes, replacing any inner diff --git a/tests/CodeShellManager.Tests/Win32CommandLineTests.cs b/tests/CodeShellManager.Tests/Win32CommandLineTests.cs new file mode 100644 index 0000000..f0b8763 --- /dev/null +++ b/tests/CodeShellManager.Tests/Win32CommandLineTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Runtime.InteropServices; +using CodeShellManager.Models; +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// Round-trips our quoting through the real Win32 tokenizer. wsl.exe is started via +/// CreateProcess with no outer shell, so what CommandLineToArgvW produces is exactly what +/// wsl.exe (and then bash -lc) receives. Hand-written expectations were how the original +/// backslash bug slipped through. +/// +public class Win32CommandLineTests +{ + [DllImport("shell32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr CommandLineToArgvW(string lpCmdLine, out int pNumArgs); + + [DllImport("kernel32.dll")] + private static extern IntPtr LocalFree(IntPtr hMem); + + internal static string[] Split(string commandLine) + { + // Prefix a dummy program name: CommandLineToArgvW parses argv[0] with different rules. + IntPtr argv = CommandLineToArgvW("x.exe " + commandLine, out int argc); + if (argv == IntPtr.Zero) throw new InvalidOperationException("CommandLineToArgvW failed"); + try + { + var result = new string[argc - 1]; + for (int i = 1; i < argc; i++) + result[i - 1] = Marshal.PtrToStringUni(Marshal.ReadIntPtr(argv, i * IntPtr.Size))!; + return result; + } + finally { LocalFree(argv); } + } + + [Theory] + [InlineData("plain")] + [InlineData("has space")] + [InlineData("say \"hi\"")] + [InlineData("trailing\\")] + [InlineData("back\\\"slash-quote")] + [InlineData("two\\\\\"bs")] + [InlineData("sed -i 's/\\\"//g' f.txt")] + [InlineData("grep -r \"\\\"foo\\\"\" .")] + [InlineData("cp -r /src /dst\\")] + [InlineData("")] + public void QuoteForCmd_RoundTripsThroughCommandLineToArgvW(string value) + { + string[] argv = Split(ShellSession.QuoteForCmd(value, force: true)); + Assert.Single(argv); + Assert.Equal(value, argv[0]); + } + + [Theory] + [InlineData("cargo test")] + [InlineData("echo \"hi\"")] + [InlineData("sed -i 's/\\\"//g' f.txt")] + [InlineData("cp -r /src /dst\\")] + [InlineData("printf '%s\n' \"$HOME\"")] + public void RunInstanceBuildWslArgs_BashPayloadArrivesIntact(string commandLine) + { + var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu", WslWorkingFolder = "/home/a b" }; + string[] argv = Split(RunInstance.BuildWslArgs(p, commandLine)); + Assert.Equal(new[] { "-d", "Ubuntu", "--cd", "/home/a b", "--", "bash", "-lc", commandLine }, argv); + } + + [Fact] + public void ShellSessionBuildWslArgs_DistroWithSpaceAndUser_Tokenizes() + { + var s = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "My Distro", WslUser = "alice", + WslWorkingFolder = "/home/alice", Command = "claude", Args = "--prompt \"fix the \\\"foo\\\" bug\"", + }; + string[] argv = Split(s.BuildWslArgs()); + Assert.Equal(new[] { "-d", "My Distro", "-u", "alice", "--cd", "/home/alice", "--", "bash", "-lc", + "claude --prompt \"fix the \\\"foo\\\" bug\"" }, argv); + } + + [Fact] + public void RunInstanceBuildWslArgs_BlankDistro_Throws() + { + var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "" }; + Assert.Throws(() => RunInstance.BuildWslArgs(p, "ls")); + } +} From cc3cb2719ffc901a99fa032ae32526b891a05880 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 22:47:58 +0200 Subject: [PATCH 31/45] test(run): cover RunInstance graceful start failure; restore printf test case Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- .../SessionRunnerTests.cs | 26 +++++++++++++++++++ .../Win32CommandLineTests.cs | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/CodeShellManager.Tests/SessionRunnerTests.cs b/tests/CodeShellManager.Tests/SessionRunnerTests.cs index 87e07fc..75443c8 100644 --- a/tests/CodeShellManager.Tests/SessionRunnerTests.cs +++ b/tests/CodeShellManager.Tests/SessionRunnerTests.cs @@ -262,6 +262,32 @@ public void RunInstance_DisposeWhileRunning_ForcesExitedFailed() Assert.NotNull(inst.EndedAt); } + // ── Graceful start failure ─────────────────────────────────────────────── + + [Fact] + public void Run_WslParentWithBlankDistro_FailsGracefullyInsteadOfThrowing() + { + // BuildWslArgs throws InvalidOperationException on a blank WslDistro. Start() + // must catch that (and anything else that can go wrong building the command + // line) and report a failed run instead of throwing out of the toolbar click. + var parent = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "" }; + var fake = new FakePseudoTerminal(); + var runner = new SessionRunner(parent, () => fake); + + int changes = 0; + runner.InstancesChanged += () => changes++; + + var inst = runner.Run(Item()); // must not throw + + Assert.Equal(RunState.ExitedFailed, inst.State); + Assert.Equal(-1, inst.ExitCode); + Assert.NotNull(inst.EndedAt); + Assert.Contains("Cannot start", inst.OutputBuffer); + Assert.Contains("WslDistro", inst.OutputBuffer); + Assert.False(fake.StartCalled, "PTY.Start must never be reached when arg-building fails."); + Assert.True(changes >= 1, $"InstancesChanged should fire so the chips UI repaints; fired={changes}"); + } + // ── Output buffer ──────────────────────────────────────────────────────── [Fact] diff --git a/tests/CodeShellManager.Tests/Win32CommandLineTests.cs b/tests/CodeShellManager.Tests/Win32CommandLineTests.cs index f0b8763..3e11264 100644 --- a/tests/CodeShellManager.Tests/Win32CommandLineTests.cs +++ b/tests/CodeShellManager.Tests/Win32CommandLineTests.cs @@ -58,7 +58,7 @@ public void QuoteForCmd_RoundTripsThroughCommandLineToArgvW(string value) [InlineData("echo \"hi\"")] [InlineData("sed -i 's/\\\"//g' f.txt")] [InlineData("cp -r /src /dst\\")] - [InlineData("printf '%s\n' \"$HOME\"")] + [InlineData("printf '%s\\n' \"$HOME\"")] public void RunInstanceBuildWslArgs_BashPayloadArrivesIntact(string commandLine) { var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu", WslWorkingFolder = "/home/a b" }; From c14494b9ce450319d79fcf4dcfe7cc9bbcaaa700 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 22:52:44 +0200 Subject: [PATCH 32/45] fix(launch): refuse unlaunchable sessions before creating a pane A WSL session with a blank distro (or SSH with a blank host) used to throw from BuildWslArgs after the WebView2 and wrapper existed and outside the failure handler, leaking both. Validate first via ShellSession.LaunchValidationError. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- src/CodeShellManager/MainWindow.xaml.cs | 12 ++++++++++++ src/CodeShellManager/Models/ShellSession.cs | 16 +++++++++++++++- .../CodeShellManager.Tests/ShellSessionTests.cs | 17 +++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 311ec78..4b5887a 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -1254,6 +1254,18 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal bool removeOnFailure = true) { Log($"LaunchSession START: cmd='{session.Command}' args='{session.Args}' folder='{session.WorkingFolder}' restoring={restoring}"); + + if (session.LaunchValidationError is { } validationError) + { + Log($"LaunchSession REFUSED: {validationError}"); + MessageBox.Show(this, $"Cannot start '{session.Name}'.\n\n{validationError}", + "Launch Error", MessageBoxButton.OK, MessageBoxImage.Warning); + if (removeOnFailure) _sessionManager.RemoveSession(session.Id); + else { session.IsDormant = true; AddDormantSidebarItem(session); } + if (_launchingSidebarItems.Remove(session.Id)) RebuildSidebarOrder(); + return; + } + var vm = new SessionViewModel(session); // Set up alert detection diff --git a/src/CodeShellManager/Models/ShellSession.cs b/src/CodeShellManager/Models/ShellSession.cs index 5e7ea3f..7ee0cc4 100644 --- a/src/CodeShellManager/Models/ShellSession.cs +++ b/src/CodeShellManager/Models/ShellSession.cs @@ -141,6 +141,20 @@ public void MigrateLegacyFields() _ => string.IsNullOrWhiteSpace(Args) ? Command : $"{Command} {Args}", }; + /// + /// Null when the session has everything it needs to launch; otherwise a sentence for + /// the user. Checked at the top of MainWindow.LaunchSessionAsync BEFORE any + /// WebView2 or PTY is created, because the arg builders throw on these and a throw at + /// that point leaks the pane. state.json and imports are untrusted input. + /// + [JsonIgnore] + public string? LaunchValidationError => Kind switch + { + SessionKind.Ssh when string.IsNullOrWhiteSpace(SshHost) => "This SSH session has no host. Edit the session and set one.", + SessionKind.Wsl when string.IsNullOrWhiteSpace(WslDistro) => "This WSL session has no distro. Edit the session and pick one.", + _ => null, + }; + /// /// Builds the argument string passed to the ssh executable. /// Example: "-t alice@dev.example.com \"cd '/proj' && bash\"" @@ -206,7 +220,7 @@ internal static string QuoteForCmd(string value, bool force = false) /// when given (run commands). It is wrapped in bash -lc so PATH-resolved tools /// (nvm node, pyenv, …) behave as in a login shell; bash then interprets the payload as /// a shell command line, which is the intent. Throws when is - /// blank — callers validate first (LaunchValidationError, Task 5). + /// blank — callers validate first (). /// internal string BuildWslArgs(string? inner = null) { diff --git a/tests/CodeShellManager.Tests/ShellSessionTests.cs b/tests/CodeShellManager.Tests/ShellSessionTests.cs index 11a28e3..d8941ba 100644 --- a/tests/CodeShellManager.Tests/ShellSessionTests.cs +++ b/tests/CodeShellManager.Tests/ShellSessionTests.cs @@ -1,3 +1,4 @@ +using System; using CodeShellManager.Models; using Xunit; @@ -213,4 +214,20 @@ public void BuildWslArgs_LinuxPathWithSpaces_QuotesCdValue() Assert.Equal("-d Ubuntu --cd \"/home/alice/my proj\" -- bash -lc \"claude\"", s.BuildWslArgs()); } + + [Fact] + public void LaunchValidationError_Local_IsNull() => + Assert.Null(new ShellSession { Kind = SessionKind.Local, Command = "claude" }.LaunchValidationError); + + [Fact] + public void LaunchValidationError_SshBlankHost_Reports() => + Assert.Contains("host", new ShellSession { Kind = SessionKind.Ssh }.LaunchValidationError!, StringComparison.OrdinalIgnoreCase); + + [Fact] + public void LaunchValidationError_WslBlankDistro_Reports() => + Assert.Contains("distro", new ShellSession { Kind = SessionKind.Wsl }.LaunchValidationError!, StringComparison.OrdinalIgnoreCase); + + [Fact] + public void LaunchValidationError_WslWithDistro_IsNull() => + Assert.Null(new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu" }.LaunchValidationError); } From 3435ba5c650854330fa6aeedf548b88b1161355b Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 22:55:12 +0200 Subject: [PATCH 33/45] fix(launch): leave the dormant fallback to the caller on a refused launch Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- src/CodeShellManager/MainWindow.xaml.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 4b5887a..fab67f7 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -1260,8 +1260,11 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal Log($"LaunchSession REFUSED: {validationError}"); MessageBox.Show(this, $"Cannot start '{session.Name}'.\n\n{validationError}", "Launch Error", MessageBoxButton.OK, MessageBoxImage.Warning); + // Mirrors the PTY-failure catch below: dormant fallback is the caller's job. + // RestartSessionAsync (the only removeOnFailure: false caller) already checks + // _sessionUi after the await and adds the dormant row itself — doing it here + // too would leave a second, untracked Border in the sidebar. if (removeOnFailure) _sessionManager.RemoveSession(session.Id); - else { session.IsDormant = true; AddDormantSidebarItem(session); } if (_launchingSidebarItems.Remove(session.Id)) RebuildSidebarOrder(); return; } From 55609f0ad6f6d96faacaef2aa1943f3fe8019cb7 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 23:00:06 +0200 Subject: [PATCH 34/45] perf(git): probe off the UI thread; slower cadence and negative cache for WSL Directory.Exists on a wsl share boots a stopped distro and was running on the dispatcher at startup and every tick. WSL sessions now poll every 30s and remember a not-a-repo answer instead of spawning wsl.exe for it forever. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- .../ViewModels/SessionViewModel.cs | 29 ++++++++++++++++--- .../SessionViewModelGitPollingTests.cs | 21 ++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 tests/CodeShellManager.Tests/SessionViewModelGitPollingTests.cs diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index f2554b4..e9b2227 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -77,6 +77,17 @@ public SessionViewModel(ShellSession session) _ = PollGitInfoAsync(_gitPollCts.Token); } + /// + /// Git poll cadence per kind. WSL probes spawn wsl.exe (much heavier than a local git + /// spawn) and defeat WSL2's idle-VM shutdown, so they run a third as often. + /// + internal static TimeSpan GitPollIntervalFor(SessionKind kind) => + kind == SessionKind.Wsl ? TimeSpan.FromSeconds(30) : TimeSpan.FromSeconds(10); + + // WSL only: a "not a repo" answer costs a wsl.exe spawn per tick, so remember it. + // Local folders keep re-probing (a `git init` should be picked up within a tick). + private bool _repoRootProbedNegative; + public async Task RefreshGitInfoAsync() { // SSH sessions have no local working folder to inspect. WSL sessions store @@ -84,7 +95,12 @@ public async Task RefreshGitInfoAsync() // 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 || _gitOverriddenByOsc) return; - var (branch, isDirty) = await GitService.GetGitInfoAsync(Session.WorkingFolder); + + // Off the dispatcher: GitService begins with a synchronous Directory.Exists, and on + // a \\wsl$ share that boots a stopped distro (seconds). Continuations return to the + // captured UI context, so the property sets below stay on the UI thread. + string folder = Session.WorkingFolder; + var (branch, isDirty) = await Task.Run(() => GitService.GetGitInfoAsync(folder)); GitBranch = branch; GitIsDirty = isDirty; GitInfoLoaded = true; @@ -92,8 +108,11 @@ public async Task RefreshGitInfoAsync() // RepoRoot is stable for the life of the session — resolve it once. Don't gate on // a non-empty branch: detached HEADs report no branch but are still valid repos // that should participate in sibling detection, shared accent color, and clusters. - if (RepoRoot == null) - RepoRoot = await GitService.GetRepoRootAsync(Session.WorkingFolder); + if (RepoRoot == null && !_repoRootProbedNegative) + { + RepoRoot = await Task.Run(() => GitService.GetRepoRootAsync(folder)); + if (RepoRoot == null && Session.Kind == SessionKind.Wsl) _repoRootProbedNegative = true; + } } /// Short repo + branch label shown beneath the session name when sibling worktrees are open. @@ -110,7 +129,7 @@ public string WorktreeSubtitle private async Task PollGitInfoAsync(CancellationToken ct) { - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10)); + using var timer = new PeriodicTimer(GitPollIntervalFor(Session.Kind)); try { while (await timer.WaitForNextTickAsync(ct)) @@ -224,6 +243,8 @@ public Task ReloadGitInfoAsync() // pushed git info via OSC 9001 was describing the old one. Let the local poller // back in until a program re-declares itself. _gitOverriddenByOsc = false; + // Same reason: a "not a repo" answer for the old folder doesn't apply to the new one. + _repoRootProbedNegative = false; return RefreshGitInfoAsync(); } diff --git a/tests/CodeShellManager.Tests/SessionViewModelGitPollingTests.cs b/tests/CodeShellManager.Tests/SessionViewModelGitPollingTests.cs new file mode 100644 index 0000000..280ee9f --- /dev/null +++ b/tests/CodeShellManager.Tests/SessionViewModelGitPollingTests.cs @@ -0,0 +1,21 @@ +using System; +using CodeShellManager.Models; +using CodeShellManager.ViewModels; +using Xunit; + +namespace CodeShellManager.Tests; + +public class SessionViewModelGitPollingTests +{ + [Fact] + public void GitPollInterval_LocalIsTenSeconds() => + Assert.Equal(TimeSpan.FromSeconds(10), SessionViewModel.GitPollIntervalFor(SessionKind.Local)); + + [Fact] + public void GitPollInterval_WslIsSlower() + { + // Each WSL probe is a wsl.exe spawn (LxssManager hop) and keeps the VM awake; + // three times the local cadence is the documented trade. + Assert.Equal(TimeSpan.FromSeconds(30), SessionViewModel.GitPollIntervalFor(SessionKind.Wsl)); + } +} From 56a6e6b210cd3f8b9995d66e369c86865d09debb Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 23:06:09 +0200 Subject: [PATCH 35/45] fix(git): don't touch a disposed session VM when the probe finishes late Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- src/CodeShellManager/ViewModels/SessionViewModel.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index e9b2227..14ca23f 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -96,11 +96,18 @@ public async Task RefreshGitInfoAsync() // Windows itself trips on those UNCs — dubious-ownership / .git symlinks). if (Session.Kind == SessionKind.Ssh || _gitOverriddenByOsc) return; + // Captured before the probe goes off-thread, not read from _gitPollCts afterwards: + // Dispose() cancels (then disposes) that CTS if the session closes while the + // Task.Run below is still in flight, and CancellationToken.IsCancellationRequested + // never throws even once the source is disposed, so this stays safe either way. + var token = _gitPollCts.Token; + // Off the dispatcher: GitService begins with a synchronous Directory.Exists, and on // a \\wsl$ share that boots a stopped distro (seconds). Continuations return to the // captured UI context, so the property sets below stay on the UI thread. string folder = Session.WorkingFolder; var (branch, isDirty) = await Task.Run(() => GitService.GetGitInfoAsync(folder)); + if (token.IsCancellationRequested) return; // session closed while the probe was off-thread GitBranch = branch; GitIsDirty = isDirty; GitInfoLoaded = true; @@ -110,7 +117,9 @@ public async Task RefreshGitInfoAsync() // that should participate in sibling detection, shared accent color, and clusters. if (RepoRoot == null && !_repoRootProbedNegative) { - RepoRoot = await Task.Run(() => GitService.GetRepoRootAsync(folder)); + string? repoRoot = await Task.Run(() => GitService.GetRepoRootAsync(folder)); + if (token.IsCancellationRequested) return; // session closed while the probe was off-thread + RepoRoot = repoRoot; if (RepoRoot == null && Session.Kind == SessionKind.Wsl) _repoRootProbedNegative = true; } } From d05d619603bff5211a639bc8e2445b88a64c45b3 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 23:11:12 +0200 Subject: [PATCH 36/45] fix(wsl): one UNC parser, and a distro-name boundary in the arg translator Ubuntu no longer matches Ubuntu-22.04 when translating wsl UNC args for git; GitService and NewSessionDialog share WslDiscoveryService.TryParseUncPath. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- src/CodeShellManager/Services/GitService.cs | 24 +++++-------------- .../Services/WslDiscoveryService.cs | 23 ++++++++++++++++++ .../Views/NewSessionDialog.xaml.cs | 19 ++++----------- .../GitServiceWslRoutingTests.cs | 16 +++++++++++++ .../WslDiscoveryServiceTests.cs | 16 +++++++++++++ 5 files changed, 65 insertions(+), 33 deletions(-) diff --git a/src/CodeShellManager/Services/GitService.cs b/src/CodeShellManager/Services/GitService.cs index 39108b5..b8b7ac0 100644 --- a/src/CodeShellManager/Services/GitService.cs +++ b/src/CodeShellManager/Services/GitService.cs @@ -249,25 +249,11 @@ public static async Task> ListBranchesAsync(string folderP /// /// Detects a \\wsl$\<distro>\… or \\wsl.localhost\<distro>\… /// path and splits it into (distro, linux-path). Returns (null, "") otherwise. + /// Delegates to — the single + /// parser for this shape shared with NewSessionDialog. /// 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, ""); - } + => WslDiscoveryService.TryParseUncPath(path); /// /// Replaces WSL UNC tokens in a git arg string with their Linux equivalents. @@ -279,7 +265,9 @@ internal static string TranslateUncArgsToLinux(string arguments, string distro) { if (string.IsNullOrEmpty(arguments)) return arguments; string esc = Regex.Escape(distro); - string body = $@"\\\\wsl(?:\$|\.localhost)\\{esc}"; + // Lookahead: the distro name must be followed by a separator, a quote, whitespace or + // end-of-string — otherwise `Ubuntu` also matches `Ubuntu-22.04`. + string body = $@"\\\\wsl(?:\$|\.localhost)\\{esc}(?=[\\""\s]|$)"; // Pass 1: quoted UNCs ("\\wsl$\\..."). The tail may contain spaces // and runs until the closing quote — without this pass, the unquoted regex diff --git a/src/CodeShellManager/Services/WslDiscoveryService.cs b/src/CodeShellManager/Services/WslDiscoveryService.cs index 884f283..fdbd3ab 100644 --- a/src/CodeShellManager/Services/WslDiscoveryService.cs +++ b/src/CodeShellManager/Services/WslDiscoveryService.cs @@ -194,4 +194,27 @@ public static string ToUncPath(string distro, string linuxPath) string trimmed = linuxPath.TrimStart('/').Replace('/', '\\'); return $@"\\wsl$\{distro}\{trimmed}"; } + + /// + /// Splits a WSL UNC (\\wsl$\Ubuntu\home\alice or \\wsl.localhost\…, either + /// slash direction) into (distro, linuxPath). linuxPath is "/" for the distro root. + /// Returns (null, "") for anything that isn't a WSL UNC. The single parser for the + /// whole app — GitService and NewSessionDialog both delegate here. + /// + public static (string? distro, string linuxPath) TryParseUncPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) return (null, ""); + string normalized = path.Replace('/', '\\').TrimEnd('\\'); + foreach (var prefix in new[] { @"\\wsl$\", @"\\wsl.localhost\" }) + { + 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)..]; + return (distro, string.IsNullOrEmpty(linuxRest) ? "/" : "/" + linuxRest.Replace('\\', '/')); + } + return (null, ""); + } } diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index ae6bc94..39936fe 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -609,21 +609,10 @@ private async System.Threading.Tasks.Task ComputeWslBrowseSeedAsync(stri /// 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 ("", ""); + var (distro, linux) = WslDiscoveryService.TryParseUncPath(unc); + // Dialog convention: blank Linux folder means "the user's home", so the distro root + // ("/") from the shared parser is reported as "" here. + return distro is null ? ("", "") : (distro, linux == "/" ? "" : linux); } private void CommandCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) diff --git a/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs b/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs index b6d9133..7af4e7d 100644 --- a/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs +++ b/tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs @@ -115,4 +115,20 @@ public void TranslateLinuxPathsToUnc_PathContainsSpaces_TranslatesWholePath() Assert.Contains(@"\\wsl$\Ubuntu\home\alice\My Projects\repo", translated); Assert.DoesNotContain("Projects/repo", translated); // no leftover forward slashes } + + [Fact] + public void TranslateUncArgsToLinux_PrefixCollidingDistro_LeftAlone() + { + // `Ubuntu` must not match `Ubuntu-22.04` — the default `wsl --install` naming. + string args = "worktree add \"\\\\wsl$\\Ubuntu-22.04\\home\\alice\\x\" main"; + Assert.Equal(args, GitService.TranslateUncArgsToLinux(args, "Ubuntu")); + string bare = "worktree add \\\\wsl$\\Ubuntu-22.04\\home\\alice\\x main"; + Assert.Equal(bare, GitService.TranslateUncArgsToLinux(bare, "Ubuntu")); + } + + [Fact] + public void TranslateUncArgsToLinux_DistroRootUnquoted_BecomesSlash() + { + Assert.Equal("-C / status", GitService.TranslateUncArgsToLinux("-C \\\\wsl$\\Ubuntu status", "Ubuntu")); + } } diff --git a/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs b/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs index 7421917..a240045 100644 --- a/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs +++ b/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs @@ -99,4 +99,20 @@ public void Parse_DistroNameWithSpace_ParsesNameCorrectly() Assert.Equal(2, result[0].Version); Assert.True(result[0].IsDefault); } + + [Theory] + [InlineData(@"\\wsl$\Ubuntu\home\alice", "Ubuntu", "/home/alice")] + [InlineData(@"\\wsl.localhost\Debian\srv\app", "Debian", "/srv/app")] + [InlineData(@"\\WSL$\Ubuntu\", "Ubuntu", "/")] + [InlineData(@"//wsl$/Ubuntu/home/alice", "Ubuntu", "/home/alice")] + [InlineData(@"\\wsl$\Ubuntu", "Ubuntu", "/")] + [InlineData(@"\\wsl$\", null, "")] + [InlineData(@"C:\proj", null, "")] + [InlineData("", null, "")] + public void TryParseUncPath_KnownShapes(string path, string? distro, string linux) + { + var (d, l) = WslDiscoveryService.TryParseUncPath(path); + Assert.Equal(distro, d); + Assert.Equal(linux, l); + } } From b7ccb20827007031c05eb7132735376598557d44 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 23:17:43 +0200 Subject: [PATCH 37/45] fix(search): relaunching a closed session from a search hit keeps its kind session_history gains a snapshot_json column holding the RecentlyClosedEntry; relaunch goes through ReopenClosedSessionAsync so WSL/SSH sessions come back as themselves. Existing databases are upgraded in InitializeSchemaAsync. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- src/CodeShellManager/MainWindow.xaml.cs | 19 ++++++- .../Services/SearchService.cs | 40 ++++++++++---- .../SearchServiceTests.cs | 52 +++++++++++++++++++ 3 files changed, 101 insertions(+), 10 deletions(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index fab67f7..37b0256 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -1359,7 +1359,8 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal { _ = _searchService.RecordSessionHistoryAsync( session.Id, session.Name, session.WorkingFolder, - session.Command, session.Args, session.GroupId); + session.Command, session.Args, session.GroupId, + System.Text.Json.JsonSerializer.Serialize(Models.RecentlyClosedEntry.FromSession(session))); if (sessionStartUtc != DateTime.MinValue && !string.IsNullOrEmpty(usageCommandKey)) { long secs = (long)(DateTime.UtcNow - sessionStartUtc).TotalSeconds; @@ -5180,6 +5181,22 @@ private async Task TryRelaunchFromHistoryAsync(string? sessionId, string? folder "Relaunch Session?", MessageBoxButton.YesNo, MessageBoxImage.Question); if (answer != MessageBoxResult.Yes) return; + // Prefer the full snapshot: it carries Kind and the SSH/WSL fields, so a WSL session + // relaunches as WSL instead of Local-at-a-UNC. Rows from before the column exist + // without one and fall back to the kind-agnostic columns. + Models.RecentlyClosedEntry? snapshot = null; + if (!string.IsNullOrEmpty(entry.SnapshotJson)) + { + try { snapshot = System.Text.Json.JsonSerializer.Deserialize(entry.SnapshotJson); } + catch (System.Text.Json.JsonException ex) { Log($"History snapshot unreadable for '{entry.SessionId}': {ex.Message}"); } + } + if (snapshot != null) + { + snapshot.MigrateLegacyFields(); + await ReopenClosedSessionAsync(snapshot); + return; + } + var newSession = _sessionManager.CreateSession( entry.SessionName, entry.WorkingFolder, entry.Command, entry.Args, entry.GroupId); SeedRunCommandsAsync(newSession); diff --git a/src/CodeShellManager/Services/SearchService.cs b/src/CodeShellManager/Services/SearchService.cs index 604f94c..c8f0b1c 100644 --- a/src/CodeShellManager/Services/SearchService.cs +++ b/src/CodeShellManager/Services/SearchService.cs @@ -26,7 +26,8 @@ public record SessionHistoryEntry( string Command, string Args, string GroupId, - DateTime ExitedAt); + DateTime ExitedAt, + string? SnapshotJson = null); public record UsageStat( string Command, @@ -89,7 +90,8 @@ CREATE TABLE IF NOT EXISTS session_history ( command TEXT NOT NULL, args TEXT NOT NULL DEFAULT '', group_id TEXT NOT NULL DEFAULT '', - exited_at INTEGER NOT NULL + exited_at INTEGER NOT NULL, + snapshot_json TEXT NULL ); CREATE INDEX IF NOT EXISTS ix_session_history_sid ON session_history(session_id); CREATE INDEX IF NOT EXISTS ix_session_history_folder ON session_history(working_folder); @@ -103,6 +105,23 @@ CREATE TABLE IF NOT EXISTS usage_stats ( ); """; await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + + // Column added after the first release of session_history; CREATE TABLE IF NOT + // EXISTS won't touch an existing table, so upgrade explicitly. Idempotent. + bool hasSnapshot = false; + await using (var probe = db.CreateCommand()) + { + probe.CommandText = "PRAGMA table_info(session_history)"; + await using var r = await probe.ExecuteReaderAsync(); + while (await r.ReadAsync()) + if (string.Equals(r.GetString(1), "snapshot_json", StringComparison.OrdinalIgnoreCase)) hasSnapshot = true; + } + if (!hasSnapshot) + { + await using var alter = db.CreateCommand(); + alter.CommandText = "ALTER TABLE session_history ADD COLUMN snapshot_json TEXT NULL"; + await alter.ExecuteNonQueryAsync(); + } } // ── Project notes ───────────────────────────────────────────────────────── @@ -228,14 +247,14 @@ private static string BuildNoteSnippet(string content, string query) public async Task RecordSessionHistoryAsync( string sessionId, string sessionName, string workingFolder, - string command, string args, string groupId) + string command, string args, string groupId, string? snapshotJson = null) { using var _dbLock = await DbGate.AcquireAsync().ConfigureAwait(false); await using var cmd = _db.CreateCommand(); cmd.CommandText = """ INSERT INTO session_history - (session_id, session_name, working_folder, command, args, group_id, exited_at) - VALUES ($sid, $name, $folder, $cmd, $args, $gid, $ts) + (session_id, session_name, working_folder, command, args, group_id, exited_at, snapshot_json) + VALUES ($sid, $name, $folder, $cmd, $args, $gid, $ts, $snap) """; cmd.Parameters.AddWithValue("$sid", sessionId); cmd.Parameters.AddWithValue("$name", sessionName); @@ -244,6 +263,7 @@ INSERT INTO session_history cmd.Parameters.AddWithValue("$args", args); cmd.Parameters.AddWithValue("$gid", groupId); cmd.Parameters.AddWithValue("$ts", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + cmd.Parameters.AddWithValue("$snap", (object?)snapshotJson ?? DBNull.Value); await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); } @@ -252,7 +272,7 @@ INSERT INTO session_history using var _dbLock = await DbGate.AcquireAsync().ConfigureAwait(false); await using var cmd = _db.CreateCommand(); cmd.CommandText = """ - SELECT session_id, session_name, working_folder, command, args, group_id, exited_at + SELECT session_id, session_name, working_folder, command, args, group_id, exited_at, snapshot_json FROM session_history WHERE session_id = $sid ORDER BY exited_at DESC LIMIT 1 """; cmd.Parameters.AddWithValue("$sid", sessionId); @@ -261,7 +281,8 @@ INSERT INTO session_history return new SessionHistoryEntry( r.GetString(0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetString(4), r.GetString(5), - DateTimeOffset.FromUnixTimeMilliseconds(r.GetInt64(6)).LocalDateTime); + DateTimeOffset.FromUnixTimeMilliseconds(r.GetInt64(6)).LocalDateTime, + r.IsDBNull(7) ? null : r.GetString(7)); } public async Task GetLatestSessionHistoryForFolderAsync(string folderPath) @@ -269,7 +290,7 @@ INSERT INTO session_history using var _dbLock = await DbGate.AcquireAsync().ConfigureAwait(false); await using var cmd = _db.CreateCommand(); cmd.CommandText = """ - SELECT session_id, session_name, working_folder, command, args, group_id, exited_at + SELECT session_id, session_name, working_folder, command, args, group_id, exited_at, snapshot_json FROM session_history WHERE working_folder = $fp ORDER BY exited_at DESC LIMIT 1 """; cmd.Parameters.AddWithValue("$fp", folderPath); @@ -278,7 +299,8 @@ INSERT INTO session_history return new SessionHistoryEntry( r.GetString(0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetString(4), r.GetString(5), - DateTimeOffset.FromUnixTimeMilliseconds(r.GetInt64(6)).LocalDateTime); + DateTimeOffset.FromUnixTimeMilliseconds(r.GetInt64(6)).LocalDateTime, + r.IsDBNull(7) ? null : r.GetString(7)); } public async Task DeleteSessionLogsAsync(string sessionId) diff --git a/tests/CodeShellManager.Tests/SearchServiceTests.cs b/tests/CodeShellManager.Tests/SearchServiceTests.cs index 5a4867d..0aba409 100644 --- a/tests/CodeShellManager.Tests/SearchServiceTests.cs +++ b/tests/CodeShellManager.Tests/SearchServiceTests.cs @@ -282,6 +282,58 @@ public async Task GetLatestSessionHistoryForFolderAsync_NoMatch_ReturnsNull() Assert.Null(entry); } + [Fact] + public async Task SessionHistory_RoundTripsSnapshotJson() + { + await _svc.RecordSessionHistoryAsync("sid-1", "n", @"\\wsl$\Ubuntu\home\a", "claude", "", "", "{\"Kind\":2}"); + var e = await _svc.GetSessionHistoryAsync("sid-1"); + Assert.NotNull(e); + Assert.Equal("{\"Kind\":2}", e!.SnapshotJson); + } + + [Fact] + public async Task SessionHistory_WithoutSnapshot_ReadsNull() + { + await _svc.RecordSessionHistoryAsync("sid-2", "n", @"C:\p", "claude", "", ""); + var e = await _svc.GetLatestSessionHistoryForFolderAsync(@"C:\p"); + Assert.Null(e!.SnapshotJson); + } + + [Fact] + public async Task InitializeSchema_UpgradesPreSnapshotTable() + { + // Simulate a database created by the previous release: same table without the column. + string path = Path.Combine(Path.GetTempPath(), $"csm-hist-{Guid.NewGuid():N}.db"); + var db = new SqliteConnection($"Data Source={path}"); + db.Open(); + try + { + await using (var cmd = db.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE session_history ( + id INTEGER PRIMARY KEY, session_id TEXT NOT NULL, session_name TEXT NOT NULL, + working_folder TEXT NOT NULL, command TEXT NOT NULL, args TEXT NOT NULL DEFAULT '', + group_id TEXT NOT NULL DEFAULT '', exited_at INTEGER NOT NULL); + INSERT INTO session_history (session_id, session_name, working_folder, command, exited_at) + VALUES ('old', 'o', 'C:\x', 'bash', 1); + """; + await cmd.ExecuteNonQueryAsync(); + } + await SearchService.InitializeSchemaAsync(db); + await SearchService.InitializeSchemaAsync(db); // idempotent + var svc = new SearchService(db); + var e = await svc.GetSessionHistoryAsync("old"); + Assert.NotNull(e); + Assert.Null(e!.SnapshotJson); + } + finally + { + db.Close(); db.Dispose(); SqliteConnection.ClearAllPools(); + try { File.Delete(path); } catch { } + } + } + // ── Storage management ────────────────────────────────────────────────── [Fact] From b4e553722488406e78e4abe1c3b4b2fe09034e64 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 23:22:42 +0200 Subject: [PATCH 38/45] fix(search): build the session-history snapshot on the UI thread Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- src/CodeShellManager/MainWindow.xaml.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 37b0256..b7e19af 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -1357,10 +1357,16 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal { if (_searchService != null) { + // RunCommands is a plain List mutated on the UI thread (SeedRunCommandsAsync, + // the run-commands editor save handler); pty.Exited fires on a background + // thread with no marshaling, so snapshot it on the dispatcher rather than + // enumerating it from here. + string snapshotJson = Dispatcher.Invoke(() => + System.Text.Json.JsonSerializer.Serialize(Models.RecentlyClosedEntry.FromSession(session))); _ = _searchService.RecordSessionHistoryAsync( session.Id, session.Name, session.WorkingFolder, session.Command, session.Args, session.GroupId, - System.Text.Json.JsonSerializer.Serialize(Models.RecentlyClosedEntry.FromSession(session))); + snapshotJson); if (sessionStartUtc != DateTime.MinValue && !string.IsNullOrEmpty(usageCommandKey)) { long secs = (long)(DateTime.UtcNow - sessionStartUtc).TotalSeconds; From db8014fca46a2575232dc83e427034e661009a17 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 23:27:03 +0200 Subject: [PATCH 39/45] fix(new-session): no double submit while the WSL home probe runs Start_Click disables the primary button for the duration and bails out if the window closed during the await, so a second Enter or a Cancel can't set DialogResult on a closed window. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- .../Views/NewSessionDialog.xaml.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index 39936fe..3d373f7 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -68,6 +68,15 @@ public partial class NewSessionDialog : Window private readonly IReadOnlyList _profiles; private readonly ShellSession? _editSession; + /// + /// Re-entrancy guards for , which is async void and + /// awaits a WSL home probe (up to 3s) with the default button still enabled. + /// _submitting blocks a second Enter/click from running a second probe; + /// _closed stops the resumed continuation from touching a closed window + /// (setting on it throws). + /// + private bool _submitting; + private bool _closed; private readonly System.Windows.Threading.DispatcherTimer _worktreeDebounce; private System.Threading.CancellationTokenSource? _worktreeProbeCts; private string? _lastProbedFolder; @@ -97,6 +106,7 @@ public NewSessionDialog( ShellSession? editSession = null) { InitializeComponent(); + Closed += (_, _) => _closed = true; FolderBox.Text = defaultFolder; _profiles = profiles ?? Array.Empty(); _preselectWslDistro = defaultSourceSession?.IsWsl == true ? defaultSourceSession.WslDistro : ""; @@ -543,6 +553,7 @@ 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()); + if (_closed) return; // 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). @@ -687,6 +698,11 @@ private void ProfileCombo_SelectionChanged(object sender, SelectionChangedEventA private async void Start_Click(object sender, RoutedEventArgs e) { + if (_submitting) return; + _submitting = true; + OkButton.IsEnabled = false; + try + { IsRemote = IsRemoteMode; IsWsl = IsWslMode; SessionName = NameBox.Text.Trim(); @@ -726,6 +742,7 @@ private async void Start_Click(object sender, RoutedEventArgs e) if (string.IsNullOrEmpty(WslWorkingFolder)) { string? home = await WslDiscoveryService.GetDistroHomeAsync(WslDistro, WslUser); + if (_closed) return; if (!string.IsNullOrEmpty(home)) WslWorkingFolder = home; } @@ -837,6 +854,15 @@ private async void Start_Click(object sender, RoutedEventArgs e) DialogResult = true; Close(); + } + finally + { + if (!_closed) + { + _submitting = false; + OkButton.IsEnabled = true; + } + } } private void Cancel_Click(object sender, RoutedEventArgs e) From fd1c0c9cbbddb0db72f55fd371ead2745f94a3c2 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 23:35:30 +0200 Subject: [PATCH 40/45] docs: WSL sessions section; Kind replaces IsRemote throughout CLAUDE.md Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- CLAUDE.md | 59 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 33c2e95..8f87cf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,6 +79,7 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js) | `PaddingParser` | WT `padding` shorthand (1/2/4 comma ints) → CSS `Npx` shorthand | | `CommandLineSplitter` | Helper — quote-aware split of a Windows commandline into `(exe, args)` | | `ShellIntegrationPayload` | WPF-free validation for the OSC 9001 channel: hex-colour check + `#rrggbbaa`→`#aarrggbb`, dirty-flag parse, title/branch sanitising (control chars stripped, 80-char cap). See "Shell Integration (OSC 9001)" | +| `WslDiscoveryService` | `wsl -l -v` parsing (UTF-16, header skipped, `*` default marker, names with spaces), `GetDistroHomeAsync` (cached `cd ~ && pwd` per distro+user), `ToUncPath` / `TryParseUncPath` — the **only** UNC↔Linux path converters; GitService and NewSessionDialog delegate here | ## Project Structure @@ -128,7 +129,7 @@ tests/ - Accent blue: `#89b4fa`, Green: `#a6e3a1`, Alert pink: `#f38ba8` - Hover: `#45475a`, Selected: `#585b70` -**Session accent colors** — `ColorService.GetHexColor(key)` uses FNV-1a hash to deterministically assign one of 12 colors. For local sessions the key is `WorkingFolder`; for SSH sessions it is `user@host`. Used as sidebar stripe + terminal toolbar top border. +**Session accent colors** — `ColorService.GetHexColor(key)` uses FNV-1a hash to deterministically assign one of 12 colors. For local sessions the key is `WorkingFolder`; for SSH sessions `user@host`; for WSL `wsl://`. Used as sidebar stripe + terminal toolbar top border. **Active-terminal highlight** — every terminal pane is wrapped in an outer "active ring" Border (constant 2px thickness, transparent by default) so toggling it doesn't shift content. `UpdateActiveTerminalHighlight` (called from `UpdateSidebarActiveState`, which fires on every `MainViewModel.ActiveSession` change) paints the ring of the active session's pane in its accent color and clears all others. @@ -151,8 +152,8 @@ The page-side `mousedown` handler also calls `fitAddon.fit()`, and the initial f ## Session Lifecycle -1. User clicks **+ New Session** → `NewSessionDialog` modal (Local or Remote SSH) -2. `SessionManager.CreateSession()` creates `ShellSession` model; caller copies SSH fields if remote +1. User clicks **+ New Session** → `NewSessionDialog` modal (Local, Remote SSH, or WSL) +2. `SessionManager.CreateSession()` creates `ShellSession` model; caller sets `Kind` and copies SSH or WSL fields; for WSL it also sets `WorkingFolder` to the `\\wsl$\\` UNC mirror (see "WSL Sessions") 3. `LaunchSessionAsync()` creates: `SessionViewModel` → `WebView2` → `TerminalBridge` → `PseudoTerminal` 4. `OutputIndexer` indexes all output to SQLite; `AlertDetector` watches for prompts 5. Termination paths: @@ -181,32 +182,40 @@ dormant row (edit a sleeping session without waking it). - Title becomes "Edit Session", the primary button becomes "Save". - "Recently closed" and the sibling-worktree checkbox list are hidden (both are create-only concepts); the worktree probe is skipped entirely (`IsEditMode` guard). -- Local/Remote radio, folder, SSH host/port/remote folder, command (matched against the - launch-command list, falling back to `[custom]`), and name are all pre-filled. +- Local/Remote/WSL radio, folder, SSH host/port/remote folder, WSL distro (pre-selected once + the async list loads; a since-uninstalled distro is kept as a `(not installed)` entry so + Save cannot wipe it), WSL user and Linux folder are all pre-filled, as is command (matched + against the launch-command list, falling back to `[custom]`), and name. - **Appearance combobox in edit mode:** when the session already carries profile overrides, the list gains a `— keep current appearance —` entry (tag `KeepCurrentAppearanceTag`, selected by default so saving never silently resets the look) and the old `— none —` entry is relabelled `— clear appearance overrides —`. The panel is shown when there are profiles to pick **or** overrides to clear, so overrides remain removable with Windows - Terminal import switched off. + Terminal import switched off. The appearance panel is shown for Local and WSL, hidden only + for SSH. **Result plumbing** — the dialog's `ToDraft()` returns a `Models.SessionConfigDraft` (a flat, UI-free snapshot of every field the form owns). `Services.SessionConfigEditor` then does the work, and being WPF-free is what makes the rules unit-testable (`tests/CodeShellManager.Tests/SessionConfigEditorTests.cs`): -- `Diff(session, draft)` → `SessionConfigChange(AnyChange, RequiresRelaunch, WorkingFolderChanged, AppearanceChanged)` +- `Diff(session, draft)` → `SessionConfigChange(AnyChange, RequiresRelaunch, WorkingFolderChanged, AppearanceChanged)`. + `WorkingFolderChanged` also fires for a WSL distro/Linux-folder change, not just a local + folder edit. - `Apply(session, draft)` writes every form-owned field verbatim (blanks included, so - clearing in the dialog really clears). Runtime state — `Id`, `GroupId`, `Status`, + clearing in the dialog really clears), including `Kind` directly; for WSL it also + **re-derives the UNC `WorkingFolder`** from the saved distro + Linux folder rather than + trusting whatever the dialog had cached. Runtime state — `Id`, `GroupId`, `Status`, `RunCommands`, `IsDormant` — is untouched. -**What needs a restart.** `RequiresRelaunch` is true for: local↔remote flip, command or +**What needs a restart.** `RequiresRelaunch` is true for: any `Kind` change, command or args change, working-folder change (path-normalized compare), any SSH target field change, -crossing the transparency boundary (opacity `< 1.0` picks a different xterm host page at -navigation time), and *clearing* an override (`TerminalBridge.ApplyProfileOverrides` only -ever **sets** options, so it can't push a value back to the global default). SSH fields and -the working folder are only compared while the session stays in the same mode, so leftovers -from a previous mode don't read as a change. +any WSL field change (distro, user, Linux folder), crossing the transparency boundary +(opacity `< 1.0` picks a different xterm host page at navigation time), and *clearing* an +override (`TerminalBridge.ApplyProfileOverrides` only ever **sets** options, so it can't push +a value back to the global default). SSH and WSL fields and the working folder are only +compared while the session stays in the same kind, so leftovers from a previous mode don't +read as a change. Everything else is applied live: `SessionViewModel.NotifyConfigChanged()` re-raises the model-mirroring properties so the sidebar row and terminal toolbar repaint in place (no @@ -228,14 +237,28 @@ Run commands are *not* part of this form; they have their own editor Remote sessions use the system `ssh` client as the PTY command — no extra library. -- `ShellSession.IsRemote` flag distinguishes remote from local sessions +- `ShellSession.Kind == SessionKind.Ssh` distinguishes remote sessions from Local/WSL ones. + `IsRemote` still exists as a `[JsonIgnore]`d two-way convenience view over `Kind` for the + SSH case (not the persisted discriminator — see "WSL Sessions"). - SSH config fields on `ShellSession`: `SshUser`, `SshHost`, `SshPort` (default 22), `SshRemoteFolder` - `ShellSession.BuildSshArgs()` (internal) produces: `-t [–p PORT] user@host "cd 'folder' && shell"` -- `LaunchSessionAsync()` branches on `IsRemote`: uses `ssh` + `BuildSshArgs()`, skips Claude auto-resume +- `LaunchSessionAsync()` branches on `Kind`: SSH sessions use `ssh` + `BuildSshArgs()`, skipping Claude auto-resume - `PseudoTerminal.BuildCmdLine` passes `ssh` through directly (same as `cmd`/`pwsh`) — not wrapped in PowerShell - `SessionViewModel.RefreshGitInfoAsync()` early-returns for remote sessions (no local working folder) - SSH fields serialize to `state.json` automatically — sessions restore and relaunch on next startup +## WSL Sessions + +`SessionKind.Wsl` launches `wsl.exe -d [-u ] --cd -- bash -lc ""` (PR #65, hardened on `feat/wsl-sessions-v2`). + +- **`Kind` is the only persisted discriminator.** `IsRemote` is a `[JsonIgnore]`d two-way convenience over `Kind` (`false` on an SSH session makes it Local; a WSL session is untouched). The legacy `"IsRemote"` JSON key lands in `LegacyIsRemote` and `StateService.Normalize` folds it into `Kind` — migration lives in the loader, never in a setter. A promote-only setter was tried first and silently broke "Edit session" (SSH→Local could not demote) and then every save (`FullCommandLine` was serialised and threw); see `ShellSessionMigrationTests`. +- **UNC mirror invariant.** A WSL session stores `WorkingFolder = WslDiscoveryService.ToUncPath(WslDistro, WslWorkingFolder)` (`\\wsl$\Ubuntu\home\alice\proj`). Explorer, the dormant row, run-command template seeding and `GitService` all work off that path unchanged. The Linux-side path lives on `WslWorkingFolder` and is what `--cd` receives. Every path that creates or edits a WSL session must keep both in step: `MainWindow` session creation, `InheritSessionKindFrom` (duplicate / worktree), `SessionConfigEditor.Apply`, `ReopenClosedSessionAsync`. +- **GitService routing.** `RunGitFullAsync` detects the UNC and runs `wsl.exe -d -- git -C …`, translating `\\wsl$` args to Linux (`TranslateUncArgsToLinux`, with a distro-name boundary so `Ubuntu` never matches `Ubuntu-22.04`) and Linux paths in stdout back to UNC. `SessionViewModel.RefreshGitInfoAsync` runs the probe on the thread pool (a `Directory.Exists` on `\\wsl$` boots a stopped distro and used to freeze the UI), polls WSL sessions every **30 s** (10 s local) and caches a "not a repo" answer for WSL so it does not spawn `wsl.exe` for it forever. +- **Quoting.** Everything that reaches `wsl.exe` goes through `ShellSession.QuoteForCmd` — MSVCRT rules (backslashes before a quote doubled, trailing backslashes doubled), verified by round-tripping through `CommandLineToArgvW` in `Win32CommandLineTests`. There is one `BuildWslArgs` (`ShellSession.BuildWslArgs(string? inner)`); `RunInstance` delegates to it and turns a build failure into a failed run chip rather than a throw. +- **Validation before UI.** `ShellSession.LaunchValidationError` (blank distro / blank SSH host) is checked at the top of `LaunchSessionAsync` before any WebView2 exists. `state.json` and imports are untrusted; a bad entry used to leak a pane. +- **Relaunch paths.** `RecentlyClosedEntry` and the `session_history.snapshot_json` column carry `Kind` + WSL fields, so Ctrl+Shift+T, the "Recently closed" list and relaunch-from-search all restore the right kind. +- **Known gaps:** WSL Claude sessions do not auto-resume on restore (`--resume` id lookup reads the Windows `~/.claude`); a distro name beginning with `-` is not defended against in `wsl.exe` option parsing. + ## Windows Terminal Profile Import (opt-in) When `AppSettings.ImportWindowsTerminalProfiles` is on, the New Session dialog reads the user's Windows Terminal `settings.json` and offers each profile in a "Profile (optional)" combobox. @@ -266,7 +289,7 @@ Closing a session (`Ctrl+W`, sidebar `✕`, or terminal-toolbar close) pushes a Sleep/wake doesn't touch the ring (`SleepSession` bypasses `OnSessionCloseRequested`). `--clean` mode clears the ring at startup (full debug isolation) and never persists changes — `SaveStateAsync` is a no-op in clean mode. -The snapshot model is `Models/RecentlyClosedEntry.cs` — a separate POCO from `ShellSession` so PTY/runtime fields (`IsDormant`, `Status`, `LastActivityAt`) don't leak into the ring buffer. `RunCommands` are deep-copied with fresh Ids on both snapshot creation and session recreation, so edits to either side never alias the other. +The snapshot model is `Models/RecentlyClosedEntry.cs` — a separate POCO from `ShellSession` so PTY/runtime fields (`IsDormant`, `Status`, `LastActivityAt`) don't leak into the ring buffer. `RunCommands` are deep-copied with fresh Ids on both snapshot creation and session recreation, so edits to either side never alias the other. Entries carry `Kind` and the SSH/WSL fields; legacy entries are migrated by `StateService.Normalize` like sessions. FTS5 scrollback retention is **out of scope** for v1 — restored sessions start with an empty xterm buffer. @@ -325,7 +348,7 @@ Each session can have a list of "run commands" — labelled command lines invoke **Data:** `ShellSession.RunCommands: List { Id, Label, CommandLine, IsDefault, Mode, PostRunUrl }`. Exactly one item has `IsDefault=true`; see `RunCommandItem.EnsureSingleDefault`. Persisted to `state.json`. -- **`Mode`** (`RunMode.Process` default / `RunMode.PowerShell`) — `Process` runs through `cmd /c` as before; `PowerShell` wraps the command line in `pwsh.exe -NonInteractive -NoLogo -ExecutionPolicy Bypass -EncodedCommand ` (falls back to `powershell.exe` if `pwsh` isn't on PATH). SSH parents ignore `Mode` — remote runs always go through bash. Use PowerShell when the command relies on pipes (`|`), redirection (`>`), `$env:` variables, or cmdlets. +- **`Mode`** (`RunMode.Process` default / `RunMode.PowerShell`) — `Process` runs through `cmd /c` as before; `PowerShell` wraps the command line in `pwsh.exe -NonInteractive -NoLogo -ExecutionPolicy Bypass -EncodedCommand ` (falls back to `powershell.exe` if `pwsh` isn't on PATH). SSH and WSL parents ignore `Mode` — those runs always go through bash (`ssh … bash -c` / `wsl.exe … bash -lc`). Use PowerShell when the command relies on pipes (`|`), redirection (`>`), `$env:` variables, or cmdlets. - **`PostRunUrl`** (`string?`, default `null`) — when set and the run exits with code 0, `Process.Start` opens the URL via `UseShellExecute=true` (default browser). No health-check polling. The value is gated by `RunInstance.IsLaunchableUrl` first: **only absolute `http`/`https` URLs are launched.** ShellExecute would otherwise run a local exe, a `.ps1`, a UNC path or any registered protocol handler, and this fires automatically with no confirmation — and `ImportExportService` deserializes a whole `AppState` (run commands included) from any JSON file the user points at, so the stored value is not trusted. Rejections and launch failures both append to `crash.log`; neither pops UI, since this runs on the PTY-exit callback thread. Scheme-less input (`localhost:5173`) is rejected rather than guessed at. **Templates:** `RunCommandTemplatesService.SeedFor(folder)` detects project type (top-level scan, first-match: dotnet → cargo → node → python → make) and returns a seed list with fresh Ids. Templates are *copied* onto new sessions at creation time; subsequent edits don't propagate back. SSH sessions skip detection (empty list). From e850cfccd2e8bb9e2a59ca9ffe6fb95931b0071e Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Mon, 7 Sep 2026 10:54:48 +0200 Subject: [PATCH 41/45] fix: hot-path buffer copy, blank-name dialog, WSL UNC resync helper, SSH rollback safety Final whole-branch review fixes (Important + hardening items): - RunInstance.OnPtyData no longer routes through AppendText on every PTY chunk. AppendText did a full ToString() snapshot of the (up to 1MB) ANSI-stripped buffer per 4KB chunk plus an OutputBuffer PropertyChanged - LOH churn on a hot path with no consumers in the app (only SnapshotOutput() is read). AppendText is kept for the Start() failure path only. - MainWindow.LaunchSessionAsync's refusal dialog no longer shows "Cannot start ''." for a session with a blank Name (the designed default) - falls back to DefaultDisplayName. This is the only user-visible output on a path that deletes a restored session. - WslDiscoveryService.ResyncWslWorkingFolder(session) is the one shared helper that re-derives WorkingFolder from WslDistro + WslWorkingFolder for a WSL session (the UNC mirror invariant). Wired into ReopenClosedSessionAsync (a hand-edited/stale RecentlyClosed entry no longer trusts a mismatched WorkingFolder) and SessionConfigEditor.Apply (replacing the inline ternary; WslDistro is now trimmed like WslWorkingFolder already was). LaunchWslConsoleFromSession's hand-copied Kind/WslDistro/WslUser/WslWorkingFolder assignments are replaced with a call to InheritSessionKindFrom - a behavior-identical subset since the new session's WorkingFolder is already the parent's known-good UNC. - ShellSession/RecentlyClosedEntry.LegacyIsRemote is now a computed getter (true for Ssh, null - omitted from JSON - otherwise) backed by a private field for the incoming legacy value on deserialize. origin/main persists only "IsRemote" and has no Kind; without this, an older build reading a state.json this one wrote turned every SSH session Local on rollback. MigrateLegacyFields still folds a legacy true into Kind and never demotes an already-Ssh/Wsl Kind. - WslDiscoveryService.TryParseUncPath now returns (null, "") rather than (empty-string, "") for a UNC with an empty distro segment (\wsl$\home\alice) - GitService tested `wslDistro != null` and would have spawned `wsl.exe -d "" -- git ...`. - ShellSession.FolderShort's Local branch uses Path.GetFileName instead of DirectoryInfo(...).Name, which throws ArgumentException on a WorkingFolder containing an embedded NUL (reachable from state.json on the restore path). Matches what DefaultDisplayName already does. - NewSessionDialog's Loaded handler starts the WSL distro probe without awaiting it before the sibling-worktree probe, then awaits both - a create-mode dialog no longer waits on a `wsl -l -v` spawn the worktree checkbox list doesn't need. Edit-mode still skips the worktree probe but still populates the distro list. Tests: SessionRunnerTests (OutputBuffer no longer touched by OnPtyData, Cannot-start assertion moved to SnapshotOutput), SessionConfigEditorTests (mirror of the stale-WSL-fields-on-Local test, for stale SSH fields on a WSL session), ShellSessionMigrationTests (updated Serialize/Normalize assertions for the computed LegacyIsRemote, new SSH round-trip test), ShellSessionTests (FolderShort with an embedded NUL), WslDiscoveryServiceTests (empty-distro UNC shape, ResyncWslWorkingFolder happy path + non-WSL no-op). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- src/CodeShellManager/MainWindow.xaml.cs | 15 +++++--- .../Models/RecentlyClosedEntry.cs | 19 +++++++--- src/CodeShellManager/Models/ShellSession.cs | 33 +++++++++++++---- src/CodeShellManager/Services/RunInstance.cs | 15 +++++++- .../Services/SessionConfigEditor.cs | 16 ++++++--- .../Services/WslDiscoveryService.cs | 18 ++++++++++ .../Views/NewSessionDialog.xaml.cs | 21 ++++++++--- .../SessionConfigEditorTests.cs | 19 ++++++++++ .../SessionRunnerTests.cs | 22 ++++++++++-- .../ShellSessionMigrationTests.cs | 36 +++++++++++++++++-- .../ShellSessionTests.cs | 11 ++++++ .../WslDiscoveryServiceTests.cs | 35 ++++++++++++++++++ 12 files changed, 229 insertions(+), 31 deletions(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index b7e19af..6a9b5b3 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -753,6 +753,10 @@ private async Task ReopenClosedSessionAsync(RecentlyClosedEntry en session.WslDistro = entry.WslDistro; session.WslUser = entry.WslUser; session.WslWorkingFolder = entry.WslWorkingFolder; + // A hand-edited or stale RecentlyClosed entry can carry a WorkingFolder that no + // longer matches WslDistro/WslWorkingFolder (see CLAUDE.md "WSL Sessions" — the + // UNC mirror invariant). Re-derive rather than trust the snapshot's WorkingFolder. + Services.WslDiscoveryService.ResyncWslWorkingFolder(session); session.ProfileFontFamily = entry.ProfileFontFamily; session.ProfileFontSize = entry.ProfileFontSize; @@ -1258,7 +1262,8 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal if (session.LaunchValidationError is { } validationError) { Log($"LaunchSession REFUSED: {validationError}"); - MessageBox.Show(this, $"Cannot start '{session.Name}'.\n\n{validationError}", + string label = string.IsNullOrWhiteSpace(session.Name) ? session.DefaultDisplayName : session.Name; + MessageBox.Show(this, $"Cannot start '{label}'.\n\n{validationError}", "Launch Error", MessageBoxButton.OK, MessageBoxImage.Warning); // Mirrors the PTY-failure catch below: dormant fallback is the caller's job. // RestartSessionAsync (the only removeOnFailure: false caller) already checks @@ -5310,10 +5315,10 @@ private void LaunchWslConsoleFromSession(Models.ShellSession parent) 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; + // session.WorkingFolder is already parent.WorkingFolder — a known-good WSL UNC — + // so InheritSessionKindFrom's "common path" branch re-derives WslWorkingFolder from + // it, which is a straight subset of the hand-copied assignments this replaces. + InheritSessionKindFrom(session, parent); _ = LaunchSessionAsync(session); } diff --git a/src/CodeShellManager/Models/RecentlyClosedEntry.cs b/src/CodeShellManager/Models/RecentlyClosedEntry.cs index 4cf1aa2..e5061cf 100644 --- a/src/CodeShellManager/Models/RecentlyClosedEntry.cs +++ b/src/CodeShellManager/Models/RecentlyClosedEntry.cs @@ -34,15 +34,26 @@ public class RecentlyClosedEntry [JsonIgnore] public bool IsRemote => Kind == SessionKind.Ssh; - /// Legacy "IsRemote" JSON slot — see . + /// + /// Legacy "IsRemote" JSON slot — same computed-getter / backing-field-setter + /// split as (see there for the full rationale): + /// written as true for an SSH entry, omitted for Local/Wsl, so a rollback reading + /// this file still sees SSH entries as remote. + /// [JsonPropertyName("IsRemote")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? LegacyIsRemote { get; set; } + public bool? LegacyIsRemote + { + get => Kind == SessionKind.Ssh ? true : (bool?)null; + set => _legacyIsRemoteIncoming = value; + } + + private bool? _legacyIsRemoteIncoming; public void MigrateLegacyFields() { - if (LegacyIsRemote == true && Kind == SessionKind.Local) Kind = SessionKind.Ssh; - LegacyIsRemote = null; + if (_legacyIsRemoteIncoming == true && Kind == SessionKind.Local) Kind = SessionKind.Ssh; + _legacyIsRemoteIncoming = null; } public string SshUser { get; set; } = ""; diff --git a/src/CodeShellManager/Models/ShellSession.cs b/src/CodeShellManager/Models/ShellSession.cs index 7ee0cc4..8b0b013 100644 --- a/src/CodeShellManager/Models/ShellSession.cs +++ b/src/CodeShellManager/Models/ShellSession.cs @@ -67,13 +67,29 @@ public bool IsRemote } /// - /// Read-only compatibility slot for the pre- "IsRemote" JSON - /// key. Populated only when an old file is deserialised; - /// folds it into and nulls it so it is never written back. + /// Compatibility slot for the pre- "IsRemote" JSON key. + /// origin/main persists only this key and has no at all — an + /// older build reading a state.json written by this one must still see an SSH + /// session as remote, so the getter is computed from rather than + /// left write-only: true for , otherwise null + /// (omitted from the JSON entirely via + /// — Local/Wsl sessions never carry this key). + /// + /// The setter does NOT share storage with the getter: during deserialization of an old + /// file it only records the incoming legacy value into ; + /// folds that into and clears it. A + /// computed getter has no state to "null itself out" after migration — once Kind is Ssh, + /// this property reads true again, which is correct (nothing left to migrate). /// [JsonPropertyName("IsRemote")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? LegacyIsRemote { get; set; } + public bool? LegacyIsRemote + { + get => Kind == SessionKind.Ssh ? true : (bool?)null; + set => _legacyIsRemoteIncoming = value; + } + + private bool? _legacyIsRemoteIncoming; /// /// Folds legacy JSON fields into their current representation. Idempotent. Called by @@ -82,8 +98,8 @@ public bool IsRemote /// public void MigrateLegacyFields() { - if (LegacyIsRemote == true && Kind == SessionKind.Local) Kind = SessionKind.Ssh; - LegacyIsRemote = null; + if (_legacyIsRemoteIncoming == true && Kind == SessionKind.Local) Kind = SessionKind.Ssh; + _legacyIsRemoteIncoming = null; } /// True iff this session runs inside a WSL distro via wsl.exe. @@ -254,7 +270,10 @@ internal string BuildWslArgs(string? inner = null) SessionKind.Wsl => BuildWslFolderShort(), _ => string.IsNullOrEmpty(WorkingFolder) ? "" - : new System.IO.DirectoryInfo(WorkingFolder).Name, + // DirectoryInfo(...).Name throws ArgumentException on a path containing an + // embedded NUL — reachable from state.json on the restore path. Path.GetFileName + // (what DefaultDisplayName already uses) tolerates it. + : System.IO.Path.GetFileName(WorkingFolder.TrimEnd('/', '\\')), }; /// diff --git a/src/CodeShellManager/Services/RunInstance.cs b/src/CodeShellManager/Services/RunInstance.cs index 8820b2d..6361f7a 100644 --- a/src/CodeShellManager/Services/RunInstance.cs +++ b/src/CodeShellManager/Services/RunInstance.cs @@ -176,7 +176,20 @@ private void OnPtyData(string text) // OutputIndexer regex so any visible quirks stay consistent across the app. // Marshal to UI thread is the consumer's responsibility — OutputChanged // fires from the PTY read loop's thread. - AppendText(AnsiPattern().Replace(text, "")); + // + // Deliberately NOT routed through AppendText: the PTY read loop hands us + // 4KB chunks, and AppendText's OutputBuffer = ToString() snapshot would be + // a full copy of the (up to 1MB) buffer per chunk — LOH churn plus a + // PropertyChanged per chunk on a hot path. OutputBuffer has no consumers + // in the app (only tests) — the app reads SnapshotOutput() on demand. + string stripped = AnsiPattern().Replace(text, ""); + lock (_bufLock) + { + _ansiStripped.Append(stripped); + if (_ansiStripped.Length > MaxBufferChars) + _ansiStripped.Remove(0, _ansiStripped.Length - MaxBufferChars); + } + OutputChanged?.Invoke(); } private void OnPtyExited() diff --git a/src/CodeShellManager/Services/SessionConfigEditor.cs b/src/CodeShellManager/Services/SessionConfigEditor.cs index 3ec05d3..feaa49f 100644 --- a/src/CodeShellManager/Services/SessionConfigEditor.cs +++ b/src/CodeShellManager/Services/SessionConfigEditor.cs @@ -104,15 +104,21 @@ public static void Apply(ShellSession s, SessionConfigDraft d) s.SshHost = d.SshHost; s.SshPort = d.SshPort; s.SshRemoteFolder = d.SshRemoteFolder; - s.WslDistro = d.WslDistro; + s.WslDistro = d.WslDistro.Trim(); s.WslUser = d.WslUser; s.WslWorkingFolder = d.WslWorkingFolder.Trim(); // WSL sessions keep WorkingFolder as the \\wsl$ UNC mirror of the Linux path so // Explorer, git polling and the sidebar need no special-casing (see CLAUDE.md - // "WSL Sessions"). Derive it here so the two can never drift apart. - s.WorkingFolder = d.Kind == SessionKind.Wsl - ? WslDiscoveryService.ToUncPath(d.WslDistro, s.WslWorkingFolder) - : d.WorkingFolder; + // "WSL Sessions"). Derive it here so the two can never drift apart — shared with + // every other path that creates/edits a WSL session (ResyncWslWorkingFolder). + if (d.Kind == SessionKind.Wsl) + { + WslDiscoveryService.ResyncWslWorkingFolder(s); + } + else + { + s.WorkingFolder = d.WorkingFolder; + } s.ProfileFontFamily = d.ProfileFontFamily; s.ProfileFontSize = d.ProfileFontSize; diff --git a/src/CodeShellManager/Services/WslDiscoveryService.cs b/src/CodeShellManager/Services/WslDiscoveryService.cs index fdbd3ab..cc709fa 100644 --- a/src/CodeShellManager/Services/WslDiscoveryService.cs +++ b/src/CodeShellManager/Services/WslDiscoveryService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using CodeShellManager.Models; namespace CodeShellManager.Services; @@ -195,6 +196,22 @@ public static string ToUncPath(string distro, string linuxPath) return $@"\\wsl$\{distro}\{trimmed}"; } + /// + /// Re-derives from + /// + when + /// the session is WSL — the "UNC mirror invariant" (see CLAUDE.md "WSL Sessions"). A no-op + /// for Local/Ssh sessions. One shared helper so every path that creates or edits a WSL + /// session — dialog creation, duplicate/worktree, session-config edit, reopen-from-history — + /// derives the same way instead of trusting a value + /// that may have been hand-edited or copied from a stale source (e.g. state.json, + /// RecentlyClosed, an import file). + /// + public static void ResyncWslWorkingFolder(ShellSession session) + { + if (session.Kind != SessionKind.Wsl) return; + session.WorkingFolder = ToUncPath((session.WslDistro ?? "").Trim(), session.WslWorkingFolder); + } + /// /// Splits a WSL UNC (\\wsl$\Ubuntu\home\alice or \\wsl.localhost\…, either /// slash direction) into (distro, linuxPath). linuxPath is "/" for the distro root. @@ -212,6 +229,7 @@ public static (string? distro, string linuxPath) TryParseUncPath(string path) if (string.IsNullOrEmpty(rest)) return (null, ""); int slash = rest.IndexOf('\\'); string distro = slash < 0 ? rest : rest[..slash]; + if (string.IsNullOrEmpty(distro)) return (null, ""); string linuxRest = slash < 0 ? "" : rest[(slash + 1)..]; return (distro, string.IsNullOrEmpty(linuxRest) ? "/" : "/" + linuxRest.Replace('\\', '/')); } diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index 3d373f7..1f41fd0 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -182,11 +182,24 @@ public NewSessionDialog( { // The distro list is needed in every mode the WSL radio can be reached from, // including edit mode — otherwise editing a WSL session shows an empty combo. - await PopulateWslDistrosAsync(); + // Started here without awaiting: the sibling-worktree probe below does not + // depend on it, and `wsl -l -v` carries its own ~3s timeout — awaiting it + // first made every create-mode dialog wait on a spawn the checkbox list + // never needed. + var distrosTask = PopulateWslDistrosAsync(); + // Sibling-worktree fan-out only makes sense when creating sessions. - if (IsEditMode) return; - if (IsLocalMode && !string.IsNullOrWhiteSpace(FolderBox.Text)) - await ProbeSiblingWorktreesAsync(FolderBox.Text.Trim()); + if (IsEditMode) + { + await distrosTask; + return; + } + + System.Threading.Tasks.Task worktreeTask = IsLocalMode && !string.IsNullOrWhiteSpace(FolderBox.Text) + ? ProbeSiblingWorktreesAsync(FolderBox.Text.Trim()) + : System.Threading.Tasks.Task.CompletedTask; + + await System.Threading.Tasks.Task.WhenAll(distrosTask, worktreeTask); }; } diff --git a/tests/CodeShellManager.Tests/SessionConfigEditorTests.cs b/tests/CodeShellManager.Tests/SessionConfigEditorTests.cs index 2c696ea..30f560a 100644 --- a/tests/CodeShellManager.Tests/SessionConfigEditorTests.cs +++ b/tests/CodeShellManager.Tests/SessionConfigEditorTests.cs @@ -390,4 +390,23 @@ public void Diff_StaleWslFieldsOnLocalSession_DoNotCount() d.WslDistro = ""; Assert.False(SessionConfigEditor.Diff(s, d).AnyChange); } + + [Fact] + public void Diff_StaleSshFieldsOnWslSession_DoNotCount() + { + // Mirror of Diff_StaleWslFieldsOnLocalSession_DoNotCount: SSH fields only count + // while the session stays SSH (see Diff's "sameKind" guards), so leftovers from + // a previous Local/Ssh mode blanked in the draft must not read as a change either. + var s = WslSession(); + s.SshUser = "leftover"; + s.SshHost = "leftover.example.com"; + s.SshPort = 2222; + s.SshRemoteFolder = "/leftover"; + var d = SessionConfigDraft.FromSession(s); + d.SshUser = ""; + d.SshHost = ""; + d.SshPort = 22; + d.SshRemoteFolder = ""; + Assert.False(SessionConfigEditor.Diff(s, d).AnyChange); + } } diff --git a/tests/CodeShellManager.Tests/SessionRunnerTests.cs b/tests/CodeShellManager.Tests/SessionRunnerTests.cs index 75443c8..3720006 100644 --- a/tests/CodeShellManager.Tests/SessionRunnerTests.cs +++ b/tests/CodeShellManager.Tests/SessionRunnerTests.cs @@ -282,8 +282,8 @@ public void Run_WslParentWithBlankDistro_FailsGracefullyInsteadOfThrowing() Assert.Equal(RunState.ExitedFailed, inst.State); Assert.Equal(-1, inst.ExitCode); Assert.NotNull(inst.EndedAt); - Assert.Contains("Cannot start", inst.OutputBuffer); - Assert.Contains("WslDistro", inst.OutputBuffer); + Assert.Contains("Cannot start", inst.SnapshotOutput()); + Assert.Contains("WslDistro", inst.SnapshotOutput()); Assert.False(fake.StartCalled, "PTY.Start must never be reached when arg-building fails."); Assert.True(changes >= 1, $"InstancesChanged should fire so the chips UI repaints; fired={changes}"); } @@ -337,6 +337,24 @@ public void RunInstance_OutputBufferCapsAtOneMegabyte() Assert.Equal(1_000_000, snap.Length); } + [Fact] + public void OnPtyData_DoesNotTouchObservableOutputBuffer() + { + // Fix 1: OnPtyData must append directly to the ANSI-stripped buffer and raise + // OutputChanged WITHOUT routing through AppendText (which would do a full + // ToString() copy + OutputBuffer PropertyChanged per 4KB PTY chunk — LOH churn + // on a hot path). OutputBuffer has no consumers in the app — SnapshotOutput() + // is what the drawer/toolbar read — so it should stay at its Start()-time value. + var fake = new FakePseudoTerminal(); + var inst = new RunInstance(Item(), () => fake); + inst.Start(LocalSession()); + + fake.EmitData("hello world\n"); + + Assert.Equal("hello world\n", inst.SnapshotOutput()); + Assert.Equal("", inst.OutputBuffer); + } + // ── Internal accessor for the private _pty field via reflection ────────── private static IPseudoTerminal? GetPty(RunInstance inst) diff --git a/tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs b/tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs index cc8c61e..e512aa5 100644 --- a/tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs +++ b/tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs @@ -25,7 +25,10 @@ public void Normalize_LegacyIsRemoteTrue_PromotesKindToSsh() var s = LoadState(legacy).Sessions[0]; Assert.Equal(SessionKind.Ssh, s.Kind); Assert.True(s.IsRemote); - Assert.Null(s.LegacyIsRemote); + // LegacyIsRemote is now a computed getter (Fix 4) — it doesn't "clear" after + // migration the way a plain nullable field did. Once Kind is Ssh it correctly + // reads true again; what matters is that migration actually happened (Kind). + Assert.True(s.LegacyIsRemote); } [Fact] @@ -60,19 +63,29 @@ public void Normalize_RecentlyClosedLegacyIsRemote_PromotesToSsh() const string legacy = """{ "RecentlyClosed": [ { "IsRemote": true, "SshHost": "h" } ] }"""; var e = LoadState(legacy).RecentlyClosed[0]; Assert.Equal(SessionKind.Ssh, e.Kind); - Assert.Null(e.LegacyIsRemote); + // Same computed-getter caveat as above — true again post-migration, not null. + Assert.True(e.LegacyIsRemote); } [Fact] public void Serialize_DoesNotWriteLegacyIsRemoteOrComputedProperties() { + // Fix 4: origin/main persists only "IsRemote" and has no Kind at all — an older + // build reading a state.json this one wrote must still see an SSH session as + // remote, so "IsRemote":true IS written for Ssh. Local/Wsl omit the key entirely. var s = new ShellSession { Kind = SessionKind.Ssh, SshHost = "h" }; string json = JsonSerializer.Serialize(s); - Assert.DoesNotContain("\"IsRemote\"", json); + Assert.Contains("\"IsRemote\":true", json); Assert.DoesNotContain("FullCommandLine", json); Assert.DoesNotContain("FolderShort", json); Assert.DoesNotContain("AccentKey", json); Assert.Contains("\"Kind\":1", json); + + var local = new ShellSession { Kind = SessionKind.Local }; + Assert.DoesNotContain("\"IsRemote\"", JsonSerializer.Serialize(local)); + + var wsl = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu" }; + Assert.DoesNotContain("\"IsRemote\"", JsonSerializer.Serialize(wsl)); } [Fact] @@ -102,6 +115,23 @@ public void IsRemoteSetter_FalseOnWsl_LeavesWsl() Assert.Equal(SessionKind.Wsl, s.Kind); } + [Fact] + public void Roundtrip_SshSession_ThroughComputedLegacyIsRemote_KindStaysSsh() + { + // serialize -> deserialize -> Normalize -> same Kind, going through the + // computed LegacyIsRemote getter/setter split from Fix 4. + var original = new ShellSession { Kind = SessionKind.Ssh, SshHost = "dev.example.com" }; + string sessionJson = JsonSerializer.Serialize(original); + Assert.Contains("\"IsRemote\":true", sessionJson); + + string wrapped = "{ \"Sessions\": [ " + sessionJson + " ] }"; + var revived = LoadState(wrapped).Sessions[0]; + + Assert.Equal(SessionKind.Ssh, revived.Kind); + Assert.True(revived.IsRemote); + Assert.Equal("dev.example.com", revived.SshHost); + } + [Fact] public void Roundtrip_NewFormat_PreservesKind() { diff --git a/tests/CodeShellManager.Tests/ShellSessionTests.cs b/tests/CodeShellManager.Tests/ShellSessionTests.cs index d8941ba..0ea5f42 100644 --- a/tests/CodeShellManager.Tests/ShellSessionTests.cs +++ b/tests/CodeShellManager.Tests/ShellSessionTests.cs @@ -230,4 +230,15 @@ public void LaunchValidationError_WslBlankDistro_Reports() => [Fact] public void LaunchValidationError_WslWithDistro_IsNull() => Assert.Null(new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu" }.LaunchValidationError); + + [Fact] + public void FolderShort_LocalWorkingFolderWithEmbeddedNul_DoesNotThrow() + { + // DirectoryInfo(...).Name throws ArgumentException on a path containing an embedded + // NUL — reachable from state.json during sidebar construction on the restore path. + // Path.GetFileName (what DefaultDisplayName already uses) tolerates it. + var s = new ShellSession { Kind = SessionKind.Local, WorkingFolder = "C:\\src\\web\u0000oops" }; + var ex = Record.Exception(() => s.FolderShort); + Assert.Null(ex); + } } diff --git a/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs b/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs index a240045..d25318b 100644 --- a/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs +++ b/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs @@ -1,4 +1,5 @@ using System.Linq; +using CodeShellManager.Models; using CodeShellManager.Services; using Xunit; @@ -107,6 +108,7 @@ public void Parse_DistroNameWithSpace_ParsesNameCorrectly() [InlineData(@"//wsl$/Ubuntu/home/alice", "Ubuntu", "/home/alice")] [InlineData(@"\\wsl$\Ubuntu", "Ubuntu", "/")] [InlineData(@"\\wsl$\", null, "")] + [InlineData(@"\\wsl$\\home\alice", null, "")] [InlineData(@"C:\proj", null, "")] [InlineData("", null, "")] public void TryParseUncPath_KnownShapes(string path, string? distro, string linux) @@ -115,4 +117,37 @@ public void TryParseUncPath_KnownShapes(string path, string? distro, string linu Assert.Equal(distro, d); Assert.Equal(linux, l); } + + [Fact] + public void ResyncWslWorkingFolder_MismatchedWorkingFolder_ReDerivesFromDistroAndLinuxFolder() + { + // Simulates a hand-edited / stale RecentlyClosed entry: WorkingFolder points + // somewhere unrelated to WslDistro + WslWorkingFolder (see CLAUDE.md "WSL + // Sessions" — the UNC mirror invariant that ReopenClosedSessionAsync must uphold). + var session = new ShellSession + { + Kind = SessionKind.Wsl, + WslDistro = "Ubuntu", + WslWorkingFolder = "/home/alice/proj", + WorkingFolder = @"C:\Windows", + }; + + WslDiscoveryService.ResyncWslWorkingFolder(session); + + Assert.Equal(@"\\wsl$\Ubuntu\home\alice\proj", session.WorkingFolder); + } + + [Fact] + public void ResyncWslWorkingFolder_NonWslSession_LeavesWorkingFolderAlone() + { + var session = new ShellSession + { + Kind = SessionKind.Local, + WorkingFolder = @"C:\src\web", + }; + + WslDiscoveryService.ResyncWslWorkingFolder(session); + + Assert.Equal(@"C:\src\web", session.WorkingFolder); + } } From 897ee822bd66a1e2131e8a2222793dabd8e49c93 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Mon, 7 Sep 2026 10:56:10 +0200 Subject: [PATCH 42/45] style(new-session): reindent the Start_Click try body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The try body was left at method-body depth when Task 9 wrapped it in try/finally. Indent one level so it reads as nested. Whitespace only — git diff -w against the parent commit is empty. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- .../Views/NewSessionDialog.xaml.cs | 250 +++++++++--------- 1 file changed, 125 insertions(+), 125 deletions(-) diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index 1f41fd0..aeaa4f5 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -716,157 +716,157 @@ private async void Start_Click(object sender, RoutedEventArgs e) OkButton.IsEnabled = false; try { - IsRemote = IsRemoteMode; - IsWsl = IsWslMode; - SessionName = NameBox.Text.Trim(); - - if (IsLocalMode && WorktreesPanel.Visibility == Visibility.Visible) - { - AdditionalWorktreePaths = WorktreesList.Children.OfType() - .Where(c => c.IsChecked == true) - .Select(c => c.Tag as string) - .Where(p => !string.IsNullOrEmpty(p)) - .Select(p => p!) - .ToList(); - } + IsRemote = IsRemoteMode; + IsWsl = IsWslMode; + SessionName = NameBox.Text.Trim(); - if (IsWsl) - { - WslDistro = (WslDistroCombo.SelectedItem as ComboBoxItem)?.Tag as string ?? ""; - if (string.IsNullOrWhiteSpace(WslDistro)) + if (IsLocalMode && WorktreesPanel.Visibility == Visibility.Visible) { - System.Windows.MessageBox.Show( - "Please select a WSL distro.", - "Distro required", MessageBoxButton.OK, MessageBoxImage.Warning); - WslDistroCombo.Focus(); - return; + AdditionalWorktreePaths = WorktreesList.Children.OfType() + .Where(c => c.IsChecked == true) + .Select(c => c.Tag as string) + .Where(p => !string.IsNullOrEmpty(p)) + .Select(p => p!) + .ToList(); } - 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)) + if (IsWsl) { - string? home = await WslDiscoveryService.GetDistroHomeAsync(WslDistro, WslUser); - if (_closed) return; - if (!string.IsNullOrEmpty(home)) WslWorkingFolder = home; - } + 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; + } - 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; + 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 (_closed) return; + if (!string.IsNullOrEmpty(home)) WslWorkingFolder = home; + } - SelectedFolder = ""; - DialogResult = true; - Close(); - return; - } + 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; - if (IsRemote) - { - if (string.IsNullOrWhiteSpace(SshHostBox.Text)) - { - System.Windows.MessageBox.Show( - "Please enter a host (e.g. user@hostname).", - "Host required", MessageBoxButton.OK, MessageBoxImage.Warning); - SshHostBox.Focus(); + SelectedFolder = ""; + DialogResult = true; + Close(); return; } - var hostRaw = SshHostBox.Text.Trim(); - var atIdx = hostRaw.IndexOf('@'); - if (atIdx > 0) + if (IsRemote) { - SshUser = hostRaw[..atIdx]; - SshHost = hostRaw[(atIdx + 1)..]; - } - else - { - SshUser = ""; - SshHost = hostRaw; - } + if (string.IsNullOrWhiteSpace(SshHostBox.Text)) + { + System.Windows.MessageBox.Show( + "Please enter a host (e.g. user@hostname).", + "Host required", MessageBoxButton.OK, MessageBoxImage.Warning); + SshHostBox.Focus(); + return; + } - SshPort = int.TryParse(SshPortBox.Text.Trim(), out int port) && port is > 0 and <= 65535 - ? port : 22; + var hostRaw = SshHostBox.Text.Trim(); + var atIdx = hostRaw.IndexOf('@'); + if (atIdx > 0) + { + SshUser = hostRaw[..atIdx]; + SshHost = hostRaw[(atIdx + 1)..]; + } + else + { + SshUser = ""; + SshHost = hostRaw; + } - SshRemoteFolder = SshRemoteFolderBox.Text.Trim(); + SshPort = int.TryParse(SshPortBox.Text.Trim(), out int port) && port is > 0 and <= 65535 + ? port : 22; - var selectedTag = (CommandCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "bash"; - if (selectedTag == "custom") - { - var (exe, args) = CommandLineSplitter.Split(CustomArgsBox.Text.Trim()); - SelectedCommand = string.IsNullOrEmpty(exe) ? "bash" : exe; - SelectedArgs = args; - } - else - { - var (exe, args) = CommandLineSplitter.Split(selectedTag); - SelectedCommand = string.IsNullOrEmpty(exe) ? "bash" : exe; - SelectedArgs = args; - } + SshRemoteFolder = SshRemoteFolderBox.Text.Trim(); - SelectedFolder = ""; - } - else - { - SelectedFolder = FolderBox.Text.Trim(); - - // Validate the folder in EDIT mode. - // - // Create mode deliberately tolerates a blank folder — LaunchSessionAsync falls - // back to %USERPROFILE% and a brand-new session in your home directory is a - // reasonable default. Editing an existing one is different: the same fallback - // silently relocates a configured session to the home folder, persists the - // empty path, and leaves git info and the accent colour keyed off nothing. - // Flipping Remote -> Local hits this every time, because a remote session has - // no local folder to pre-fill from. - if (IsEditMode) - { - if (string.IsNullOrWhiteSpace(SelectedFolder)) + var selectedTag = (CommandCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "bash"; + if (selectedTag == "custom") { - System.Windows.MessageBox.Show( - "Please choose a working folder for this session.", - "Working folder required", MessageBoxButton.OK, MessageBoxImage.Warning); - FolderBox.Focus(); - return; + var (exe, args) = CommandLineSplitter.Split(CustomArgsBox.Text.Trim()); + SelectedCommand = string.IsNullOrEmpty(exe) ? "bash" : exe; + SelectedArgs = args; } - if (!System.IO.Directory.Exists(SelectedFolder)) + else { - System.Windows.MessageBox.Show( - $"That folder doesn't exist:\n\n{SelectedFolder}\n\n" + - "Pick a folder that exists, or the session will fail to start.", - "Folder not found", MessageBoxButton.OK, MessageBoxImage.Warning); - FolderBox.Focus(); - return; + var (exe, args) = CommandLineSplitter.Split(selectedTag); + SelectedCommand = string.IsNullOrEmpty(exe) ? "bash" : exe; + SelectedArgs = args; } - } - var selectedTag = (CommandCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "claude"; - if (selectedTag == "custom") - { - var (exe, args) = CommandLineSplitter.Split(CustomArgsBox.Text.Trim()); - SelectedCommand = string.IsNullOrEmpty(exe) ? "claude" : exe; - SelectedArgs = args; + SelectedFolder = ""; } else { - var (exe, args) = CommandLineSplitter.Split(selectedTag); - SelectedCommand = string.IsNullOrEmpty(exe) ? "claude" : exe; - SelectedArgs = args; + SelectedFolder = FolderBox.Text.Trim(); + + // Validate the folder in EDIT mode. + // + // Create mode deliberately tolerates a blank folder — LaunchSessionAsync falls + // back to %USERPROFILE% and a brand-new session in your home directory is a + // reasonable default. Editing an existing one is different: the same fallback + // silently relocates a configured session to the home folder, persists the + // empty path, and leaves git info and the accent colour keyed off nothing. + // Flipping Remote -> Local hits this every time, because a remote session has + // no local folder to pre-fill from. + if (IsEditMode) + { + if (string.IsNullOrWhiteSpace(SelectedFolder)) + { + System.Windows.MessageBox.Show( + "Please choose a working folder for this session.", + "Working folder required", MessageBoxButton.OK, MessageBoxImage.Warning); + FolderBox.Focus(); + return; + } + if (!System.IO.Directory.Exists(SelectedFolder)) + { + System.Windows.MessageBox.Show( + $"That folder doesn't exist:\n\n{SelectedFolder}\n\n" + + "Pick a folder that exists, or the session will fail to start.", + "Folder not found", MessageBoxButton.OK, MessageBoxImage.Warning); + FolderBox.Focus(); + return; + } + } + + var selectedTag = (CommandCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "claude"; + if (selectedTag == "custom") + { + var (exe, args) = CommandLineSplitter.Split(CustomArgsBox.Text.Trim()); + SelectedCommand = string.IsNullOrEmpty(exe) ? "claude" : exe; + SelectedArgs = args; + } + else + { + var (exe, args) = CommandLineSplitter.Split(selectedTag); + SelectedCommand = string.IsNullOrEmpty(exe) ? "claude" : exe; + SelectedArgs = args; + } } - } - DialogResult = true; - Close(); + DialogResult = true; + Close(); } finally { From 739d4ddd948b4deeeeaa642b03520a28bd64050a Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Mon, 7 Sep 2026 20:46:12 +0200 Subject: [PATCH 43/45] fix(wsl): -e not --, bash-to-sh fallback, filter docker-desktop distros MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real bugs from manual WSL testing: 1. BuildWslArgs used `-- bash -lc` as the wsl.exe separator, which runs the payload through the distro's *default* login shell before our own `bash -lc` ever sees it — a second, unwanted expansion pass that mangles any command using $vars, `backticks`, globs or ~. Switched to `-e ` (--exec), which runs the shell directly. Verified empirically: a loop with `-- bash -lc` printed empty loop vars; the same command with `-e bash` worked correctly. 2. BuildWslArgs hardcoded "bash" as the login shell, so every session and run command failed on distros with no bash (Alpine, BusyBox images, Docker Desktop's own docker-desktop distro) — even an empty command box, since the payload became `bash -lc "bash"`. Added WslDiscoveryService.GetLoginShellAsync (cached bash-or-sh probe, mirrors GetDistroHomeAsync, defaults to "bash" on any failure) and a runtime-only ShellSession.ResolvedWslShell that MainWindow.LaunchSessionAsync resolves before building args; run commands inherit it via the shared ShellSession instance. NewSessionDialog now leaves an empty WSL command box empty instead of persisting a literal "bash" that fails on sh-only distros. 3. WslDiscoveryService.Parse offered docker-desktop / docker-desktop-data in the distro picker — Docker's own BusyBox plumbing, not a user environment, and often the only entry on a dev machine. Filtered both out (exact, case-insensitive match only, so a name that merely contains the phrase is kept). Updated CLAUDE.md's WSL Sessions section and the WslDiscoveryService Services row accordingly. 10 new tests (ShellSessionTests, RunInstanceTests, Win32CommandLineTests, WslDiscoveryServiceTests); 457 -> 467 passing, 0 failures. Build clean, 0 warnings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- CLAUDE.md | 9 ++- src/CodeShellManager/MainWindow.xaml.cs | 9 +++ src/CodeShellManager/Models/ShellSession.cs | 41 +++++++++-- .../Services/WslDiscoveryService.cs | 73 +++++++++++++++++++ .../Views/NewSessionDialog.xaml.cs | 8 +- .../RunInstanceTests.cs | 18 ++++- .../ShellSessionTests.cs | 56 +++++++++++++- .../Win32CommandLineTests.cs | 4 +- .../WslDiscoveryServiceTests.cs | 64 ++++++++++++++++ 9 files changed, 262 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8f87cf7..a221487 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js) | `PaddingParser` | WT `padding` shorthand (1/2/4 comma ints) → CSS `Npx` shorthand | | `CommandLineSplitter` | Helper — quote-aware split of a Windows commandline into `(exe, args)` | | `ShellIntegrationPayload` | WPF-free validation for the OSC 9001 channel: hex-colour check + `#rrggbbaa`→`#aarrggbb`, dirty-flag parse, title/branch sanitising (control chars stripped, 80-char cap). See "Shell Integration (OSC 9001)" | -| `WslDiscoveryService` | `wsl -l -v` parsing (UTF-16, header skipped, `*` default marker, names with spaces), `GetDistroHomeAsync` (cached `cd ~ && pwd` per distro+user), `ToUncPath` / `TryParseUncPath` — the **only** UNC↔Linux path converters; GitService and NewSessionDialog delegate here | +| `WslDiscoveryService` | `wsl -l -v` parsing (UTF-16, header skipped, `*` default marker, names with spaces, Docker-internal distros filtered), `GetDistroHomeAsync` (cached `cd ~ && pwd` per distro+user), `GetLoginShellAsync` (cached `bash`-or-`sh` probe per distro+user, defaults to `bash` on any failure), `ToUncPath` / `TryParseUncPath` — the **only** UNC↔Linux path converters; GitService and NewSessionDialog delegate here | ## Project Structure @@ -249,11 +249,14 @@ Remote sessions use the system `ssh` client as the PTY command — no extra libr ## WSL Sessions -`SessionKind.Wsl` launches `wsl.exe -d [-u ] --cd -- bash -lc ""` (PR #65, hardened on `feat/wsl-sessions-v2`). +`SessionKind.Wsl` launches `wsl.exe -d [-u ] [--cd ] -e -lc ""` (PR #65, hardened on `feat/wsl-sessions-v2`; `-e` replaced a bare `--` separator during the same hardening — see below). - **`Kind` is the only persisted discriminator.** `IsRemote` is a `[JsonIgnore]`d two-way convenience over `Kind` (`false` on an SSH session makes it Local; a WSL session is untouched). The legacy `"IsRemote"` JSON key lands in `LegacyIsRemote` and `StateService.Normalize` folds it into `Kind` — migration lives in the loader, never in a setter. A promote-only setter was tried first and silently broke "Edit session" (SSH→Local could not demote) and then every save (`FullCommandLine` was serialised and threw); see `ShellSessionMigrationTests`. - **UNC mirror invariant.** A WSL session stores `WorkingFolder = WslDiscoveryService.ToUncPath(WslDistro, WslWorkingFolder)` (`\\wsl$\Ubuntu\home\alice\proj`). Explorer, the dormant row, run-command template seeding and `GitService` all work off that path unchanged. The Linux-side path lives on `WslWorkingFolder` and is what `--cd` receives. Every path that creates or edits a WSL session must keep both in step: `MainWindow` session creation, `InheritSessionKindFrom` (duplicate / worktree), `SessionConfigEditor.Apply`, `ReopenClosedSessionAsync`. -- **GitService routing.** `RunGitFullAsync` detects the UNC and runs `wsl.exe -d -- git -C …`, translating `\\wsl$` args to Linux (`TranslateUncArgsToLinux`, with a distro-name boundary so `Ubuntu` never matches `Ubuntu-22.04`) and Linux paths in stdout back to UNC. `SessionViewModel.RefreshGitInfoAsync` runs the probe on the thread pool (a `Directory.Exists` on `\\wsl$` boots a stopped distro and used to freeze the UI), polls WSL sessions every **30 s** (10 s local) and caches a "not a repo" answer for WSL so it does not spawn `wsl.exe` for it forever. +- **`-e`, not `--`.** `wsl.exe -- …` runs the trailing command through the distro's *default* login shell before our own ` -lc "…"` ever sees it — a second, unwanted expansion pass in the wrong environment. Verified empirically: `wsl -d Ubuntu -- bash -lc 'for t in a b; do echo "L=$t"; done'` printed `L=` / `L=` (the loop variable never survived the first pass); the same command with `-e bash` printed `L=a` / `L=b`. `-e`/`--exec` runs the given program directly, skipping that pass, and composes fine with both `--cd` and `-u`. `--` looks more natural here — resist the urge to change it back. Any user command or run command containing `$var`, `` `…` ``, globs or `~` was silently mangled before this fix. +- **Login-shell fallback (bash → sh).** `BuildWslArgs` no longer hardcodes `bash` as the login shell — minimal distros (Alpine, BusyBox images, Docker Desktop's own `docker-desktop` distro) have no bash, so hardcoding it failed *every* session and run command there, no matter what the user typed. `WslDiscoveryService.GetLoginShellAsync(distro, user)` probes `-e sh -c "command -v bash >/dev/null 2>&1 && echo bash || echo sh"` (mirrors `GetDistroHomeAsync`: cached per `(distro, user)`, 3s timeout, drains both stdout and stderr, never throws) and returns `"bash"` on any failure — that preserves prior behaviour rather than silently downgrading a distro that actually works. `MainWindow.LaunchSessionAsync` resolves it into the runtime-only `ShellSession.ResolvedWslShell` (`[JsonIgnore] internal`, never persisted — a distro's shells can change between runs) before calling `BuildWslArgs`, which uses `ResolvedWslShell ?? "bash"` for both the `-e ` login shell and the empty-`Command` fallback payload. Run commands share the resolved value for free since `RunInstance` reads the same `ShellSession` instance — no second probe. +- **Docker-internal distros are filtered from the picker.** `WslDiscoveryService.Parse` drops `docker-desktop` and `docker-desktop-data` (exact, case-insensitive match — a name that merely *contains* the phrase, e.g. `my-docker-desktop-clone`, is kept) before the listing reaches `NewSessionDialog`. Those are Docker's own BusyBox-based plumbing — root-only, rebuilt on Docker updates, not a user environment — and offering them (often as the *only* entry on a dev machine) is how a maintainer here hit the bash-not-found bug during manual testing. A listing containing only Docker distros now returns empty, so the dialog falls through to its existing "No WSL distros found" hint. +- **GitService routing.** `RunGitFullAsync` detects the UNC and runs `wsl.exe -d -- git -C …`, translating `\\wsl$` args to Linux (`TranslateUncArgsToLinux`, with a distro-name boundary so `Ubuntu` never matches `Ubuntu-22.04`) and Linux paths in stdout back to UNC. This path still uses `--` deliberately, not `-e`: `git` is invoked with a literal argv, not a shell-interpreted string payload, so there is no second expansion pass to avoid. `SessionViewModel.RefreshGitInfoAsync` runs the probe on the thread pool (a `Directory.Exists` on `\\wsl$` boots a stopped distro and used to freeze the UI), polls WSL sessions every **30 s** (10 s local) and caches a "not a repo" answer for WSL so it does not spawn `wsl.exe` for it forever. - **Quoting.** Everything that reaches `wsl.exe` goes through `ShellSession.QuoteForCmd` — MSVCRT rules (backslashes before a quote doubled, trailing backslashes doubled), verified by round-tripping through `CommandLineToArgvW` in `Win32CommandLineTests`. There is one `BuildWslArgs` (`ShellSession.BuildWslArgs(string? inner)`); `RunInstance` delegates to it and turns a build failure into a failed run chip rather than a throw. - **Validation before UI.** `ShellSession.LaunchValidationError` (blank distro / blank SSH host) is checked at the top of `LaunchSessionAsync` before any WebView2 exists. `state.json` and imports are untrusted; a bad entry used to leak a pane. - **Relaunch paths.** `RecentlyClosedEntry` and the `session_history.snapshot_json` column carry `Kind` + WSL fields, so Ctrl+Shift+T, the "Recently closed" list and relaunch-from-search all restore the right kind. diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 6a9b5b3..91986d9 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -1274,6 +1274,15 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal return; } + if (session.Kind == Models.SessionKind.Wsl) + { + // Resolve before BuildWslArgs runs below — minimal distros (Alpine, BusyBox + // images, Docker Desktop's own distro) have no bash, so hardcoding it fails + // every launch there. Run commands inherit this via the same ShellSession + // instance (RunInstance.BuildWslArgs delegates to session.BuildWslArgs). + session.ResolvedWslShell = await WslDiscoveryService.GetLoginShellAsync(session.WslDistro, session.WslUser); + } + var vm = new SessionViewModel(session); // Set up alert detection diff --git a/src/CodeShellManager/Models/ShellSession.cs b/src/CodeShellManager/Models/ShellSession.cs index 8b0b013..7061bcb 100644 --- a/src/CodeShellManager/Models/ShellSession.cs +++ b/src/CodeShellManager/Models/ShellSession.cs @@ -120,6 +120,19 @@ public void MigrateLegacyFields() /// Linux-style working folder inside the distro, e.g. "/home/alice/project". Empty = the user's home. public string WslWorkingFolder { get; set; } = ""; + /// + /// Runtime-only cache of the distro's login shell ("bash" or "sh"), resolved by + /// in + /// MainWindow.LaunchSessionAsync before is called. + /// Deliberately NOT persisted — a distro's available shells can change between runs + /// (e.g. a minimal image gains bash after an update), so this is always re-probed at + /// launch rather than trusted from a prior session. Run commands share this via the + /// same instance, so they never re-probe. Null until resolved, + /// in which case falls back to "bash". + /// + [JsonIgnore] + internal string? ResolvedWslShell { 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 @@ -231,17 +244,31 @@ internal static string QuoteForCmd(string value, bool force = false) /// /// Builds the argument string passed to wsl.exe: - /// -d <distro> [-u <user>] [--cd <linux-folder>] -- bash -lc "<payload>". + /// -d <distro> [-u <user>] [--cd <linux-folder>] -e <shell> -lc "<payload>". /// The payload is + , or - /// when given (run commands). It is wrapped in bash -lc so PATH-resolved tools - /// (nvm node, pyenv, …) behave as in a login shell; bash then interprets the payload as - /// a shell command line, which is the intent. Throws when is - /// blank — callers validate first (). + /// when given (run commands). It is wrapped in <shell> -lc so PATH-resolved + /// tools (nvm node, pyenv, …) behave as in a login shell; the shell then interprets the + /// payload as a shell command line, which is the intent. Shell is + /// when set (probed per-distro by + /// ), else "bash". + /// + /// -e (not --) is deliberate: wsl.exe <cmd> -- … runs the + /// trailing command through the distro's *default* login shell before anything after + /// -- ever runs, so a payload built for our shell gets expanded twice — once by + /// that default shell (in the wrong environment) and once by ours. -e/--exec + /// executes the given program directly, skipping that first pass. -- looks more + /// natural here — resist the urge to change it back; verified empirically: + /// wsl -d Ubuntu -- bash -lc 'for t in a b; do echo "L=$t"; done' printed + /// "L=" / "L=" (mangled) while the same command with -e bash printed "L=a" / "L=b". + /// + /// Throws when is blank — callers validate first + /// (). /// internal string BuildWslArgs(string? inner = null) { if (string.IsNullOrWhiteSpace(WslDistro)) throw new InvalidOperationException("WslDistro must be set for WSL sessions."); + string loginShell = ResolvedWslShell ?? "bash"; var sb = new StringBuilder(); sb.Append("-d ").Append(QuoteForCmd(WslDistro)); if (!string.IsNullOrWhiteSpace(WslUser)) @@ -250,10 +277,10 @@ internal string BuildWslArgs(string? inner = null) sb.Append(" --cd ").Append(QuoteForCmd(WslWorkingFolder)); if (inner is null) { - var shell = string.IsNullOrWhiteSpace(Command) ? "bash" : Command; + var shell = string.IsNullOrWhiteSpace(Command) ? loginShell : Command; inner = string.IsNullOrWhiteSpace(Args) ? shell : $"{shell} {Args}"; } - sb.Append(" -- bash -lc ").Append(QuoteForCmd(inner, force: true)); + sb.Append(" -e ").Append(QuoteForCmd(loginShell)).Append(" -lc ").Append(QuoteForCmd(inner, force: true)); return sb.ToString(); } diff --git a/src/CodeShellManager/Services/WslDiscoveryService.cs b/src/CodeShellManager/Services/WslDiscoveryService.cs index cc709fa..0e219f5 100644 --- a/src/CodeShellManager/Services/WslDiscoveryService.cs +++ b/src/CodeShellManager/Services/WslDiscoveryService.cs @@ -115,11 +115,25 @@ internal static IReadOnlyList Parse(string raw) } // Stable ordering: default first, then alphabetical. return results + .Where(d => !IsDockerInternalDistro(d.Name)) .OrderByDescending(d => d.IsDefault) .ThenBy(d => d.Name, StringComparer.OrdinalIgnoreCase) .ToList(); } + /// + /// True for Docker Desktop's own internal distros ("docker-desktop" and, on older + /// versions, "docker-desktop-data") — BusyBox-based plumbing that Docker rebuilds on + /// its own updates, not a user environment. They have no bash (and are root-only), so + /// offering them in the picker just hands the user a confusing "bash: not found" the + /// first time they try to use one — which is exactly what happened during manual + /// testing here. Exact, case-insensitive match only: a user-imported distro that merely + /// *contains* the phrase (e.g. "my-docker-desktop-clone") must still be offered. + /// + private static bool IsDockerInternalDistro(string name) => + string.Equals(name, "docker-desktop", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "docker-desktop-data", StringComparison.OrdinalIgnoreCase); + /// /// 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) — @@ -182,6 +196,65 @@ internal static IReadOnlyList Parse(string raw) private static readonly Dictionary _homeCache = new(); + /// + /// Resolves the login shell to use inside a WSL distro — "bash" when it's present, + /// "sh" otherwise (minimal distros like Alpine/BusyBox images or Docker Desktop's own + /// "docker-desktop" distro have no bash, so hardcoding it fails every session and run + /// command there). Cached per (distro, user) exactly like . + /// Never throws; any failure (WSL not running, timeout, non-zero exit) returns "bash" — + /// that preserves today's behaviour rather than silently downgrading a distro that + /// actually works. + /// + public static async Task GetLoginShellAsync(string distro, string? user = null) + { + if (string.IsNullOrWhiteSpace(distro)) return "bash"; + string normalizedUser = user?.Trim() ?? ""; + string key = $"{distro}|{normalizedUser}"; + lock (_shellCache) + { + if (_shellCache.TryGetValue(key, out var cached)) return cached; + } + + try + { + string args = $"-d {Models.ShellSession.QuoteForCmd(distro)}"; + if (!string.IsNullOrEmpty(normalizedUser)) + args += $" -u {Models.ShellSession.QuoteForCmd(normalizedUser)}"; + // -e (not --) for the same reason BuildWslArgs uses it — see ShellSession. + args += " -e sh -c \"command -v bash >/dev/null 2>&1 && echo bash || echo sh\""; + + 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 "bash"; + + // Drain both streams — see GetDistroHomeAsync for why stderr must be read too. + var outTask = process.StandardOutput.ReadToEndAsync(); + 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 "bash"; } + try { await process.WaitForExitAsync(); } catch { } + if (process.ExitCode != 0) return "bash"; + + string result = outTask.Result.Trim(); + string shell = result == "sh" ? "sh" : "bash"; + lock (_shellCache) _shellCache[key] = shell; + return shell; + } + catch (Exception) { return "bash"; } + } + + private static readonly Dictionary _shellCache = 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.cs b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs index aeaa4f5..7feb6e7 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml.cs +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml.cs @@ -759,10 +759,14 @@ private async void Start_Click(object sender, RoutedEventArgs e) if (!string.IsNullOrEmpty(home)) WslWorkingFolder = home; } - var selectedTag = (CommandCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "bash"; + var selectedTag = (CommandCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? ""; string raw = selectedTag == "custom" ? CustomArgsBox.Text.Trim() : selectedTag; var (exe, args) = CommandLineSplitter.Split(raw); - SelectedCommand = string.IsNullOrEmpty(exe) ? "bash" : exe; + // Blank stays blank rather than hardcoding "bash": a minimal distro (Alpine, + // Docker Desktop's own distro) has no bash, so persisting the literal name + // fails there. ShellSession.BuildWslArgs falls back to the resolved login + // shell (ResolvedWslShell, probed at launch) when Command is empty. + SelectedCommand = exe; SelectedArgs = args; SelectedFolder = ""; diff --git a/tests/CodeShellManager.Tests/RunInstanceTests.cs b/tests/CodeShellManager.Tests/RunInstanceTests.cs index d427355..b086569 100644 --- a/tests/CodeShellManager.Tests/RunInstanceTests.cs +++ b/tests/CodeShellManager.Tests/RunInstanceTests.cs @@ -91,7 +91,7 @@ public void BuildWslArgs_HappyPath_BuildsExpectedShape() string args = RunInstance.BuildWslArgs(p, "cargo test"); // 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); + Assert.Equal("-d Ubuntu -u alice --cd /home/alice/proj -e bash -lc \"cargo test\"", args); } [Fact] @@ -99,7 +99,21 @@ 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 -e bash -lc \"ls\"", args); + } + + [Fact] + public void BuildWslArgs_ParentResolvedShell_RunCommandInheritsIt() + { + // Run commands delegate to ShellSession.BuildWslArgs on the same parent instance, so + // a shell resolved for the interactive session (e.g. "sh" on a bash-less distro) must + // be used for its run commands too, without a second probe. + var p = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "docker-desktop", ResolvedWslShell = "sh", + }; + string args = RunInstance.BuildWslArgs(p, "echo hi"); + Assert.Equal("-d docker-desktop -e sh -lc \"echo hi\"", args); } [Fact] diff --git a/tests/CodeShellManager.Tests/ShellSessionTests.cs b/tests/CodeShellManager.Tests/ShellSessionTests.cs index 0ea5f42..241d612 100644 --- a/tests/CodeShellManager.Tests/ShellSessionTests.cs +++ b/tests/CodeShellManager.Tests/ShellSessionTests.cs @@ -116,7 +116,7 @@ public void BuildWslArgs_HappyPath_BuildsExpectedShape() 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\"", + Assert.Equal("-d Ubuntu -u alice --cd /home/alice/proj -e bash -lc \"claude\"", s.BuildWslArgs()); } @@ -128,7 +128,7 @@ public void BuildWslArgs_NoUser_OmitsUserFlag() Kind = SessionKind.Wsl, WslDistro = "Debian", WslWorkingFolder = "/srv", Command = "bash", }; - Assert.Equal("-d Debian --cd /srv -- bash -lc \"bash\"", s.BuildWslArgs()); + Assert.Equal("-d Debian --cd /srv -e bash -lc \"bash\"", s.BuildWslArgs()); } [Fact] @@ -138,7 +138,55 @@ public void BuildWslArgs_NoWorkingFolder_OmitsCdFlag() { Kind = SessionKind.Wsl, WslDistro = "Ubuntu", Command = "bash", }; - Assert.Equal("-d Ubuntu -- bash -lc \"bash\"", s.BuildWslArgs()); + Assert.Equal("-d Ubuntu -e bash -lc \"bash\"", s.BuildWslArgs()); + } + + [Fact] + public void BuildWslArgs_UsesExecFlag_NotBareDashDashSeparator() + { + // Fix for wsl.exe pre-expanding our payload: "--" runs the trailing command through + // the distro's default login shell first (a second, unwanted expansion pass), while + // "-e"/"--exec" runs it directly. Guard both that -e is present and that no bare "--" + // token slipped back in (a substring check alone wouldn't catch "--cd"). + var s = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "Ubuntu", Command = "claude", + }; + string args = s.BuildWslArgs(); + Assert.Contains(" -e ", args); + Assert.DoesNotContain(args.Split(' '), token => token == "--"); + } + + [Fact] + public void BuildWslArgs_ResolvedWslShellSet_UsedAsExecShell() + { + var s = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "docker-desktop", Command = "ls", + ResolvedWslShell = "sh", + }; + Assert.Equal("-d docker-desktop -e sh -lc \"ls\"", s.BuildWslArgs()); + } + + [Fact] + public void BuildWslArgs_ResolvedWslShellUnset_DefaultsToBash() + { + var s = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu", Command = "ls" }; + Assert.Equal("-d Ubuntu -e bash -lc \"ls\"", s.BuildWslArgs()); + } + + [Fact] + public void BuildWslArgs_ResolvedWslShellSet_EmptyCommand_InnerPayloadUsesResolvedShellToo() + { + // The hardcoded "bash" fallback for a blank Command must become the resolved shell + // too — otherwise an empty command box on a sh-only distro still emits "bash" as the + // *inner* payload (bash -lc "bash"), which fails identically to the outer bug. + var s = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "docker-desktop", Command = "", + ResolvedWslShell = "sh", + }; + Assert.Equal("-d docker-desktop -e sh -lc \"sh\"", s.BuildWslArgs()); } [Fact] @@ -211,7 +259,7 @@ public void BuildWslArgs_LinuxPathWithSpaces_QuotesCdValue() Kind = SessionKind.Wsl, WslDistro = "Ubuntu", WslWorkingFolder = "/home/alice/my proj", Command = "claude", }; - Assert.Equal("-d Ubuntu --cd \"/home/alice/my proj\" -- bash -lc \"claude\"", + Assert.Equal("-d Ubuntu --cd \"/home/alice/my proj\" -e bash -lc \"claude\"", s.BuildWslArgs()); } diff --git a/tests/CodeShellManager.Tests/Win32CommandLineTests.cs b/tests/CodeShellManager.Tests/Win32CommandLineTests.cs index 3e11264..403ea62 100644 --- a/tests/CodeShellManager.Tests/Win32CommandLineTests.cs +++ b/tests/CodeShellManager.Tests/Win32CommandLineTests.cs @@ -63,7 +63,7 @@ public void RunInstanceBuildWslArgs_BashPayloadArrivesIntact(string commandLine) { var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu", WslWorkingFolder = "/home/a b" }; string[] argv = Split(RunInstance.BuildWslArgs(p, commandLine)); - Assert.Equal(new[] { "-d", "Ubuntu", "--cd", "/home/a b", "--", "bash", "-lc", commandLine }, argv); + Assert.Equal(new[] { "-d", "Ubuntu", "--cd", "/home/a b", "-e", "bash", "-lc", commandLine }, argv); } [Fact] @@ -75,7 +75,7 @@ public void ShellSessionBuildWslArgs_DistroWithSpaceAndUser_Tokenizes() WslWorkingFolder = "/home/alice", Command = "claude", Args = "--prompt \"fix the \\\"foo\\\" bug\"", }; string[] argv = Split(s.BuildWslArgs()); - Assert.Equal(new[] { "-d", "My Distro", "-u", "alice", "--cd", "/home/alice", "--", "bash", "-lc", + Assert.Equal(new[] { "-d", "My Distro", "-u", "alice", "--cd", "/home/alice", "-e", "bash", "-lc", "claude --prompt \"fix the \\\"foo\\\" bug\"" }, argv); } diff --git a/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs b/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs index d25318b..94f2a2a 100644 --- a/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs +++ b/tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs @@ -1,4 +1,5 @@ using System.Linq; +using System.Threading.Tasks; using CodeShellManager.Models; using CodeShellManager.Services; using Xunit; @@ -150,4 +151,67 @@ public void ResyncWslWorkingFolder_NonWslSession_LeavesWorkingFolderAlone() Assert.Equal(@"C:\src\web", session.WorkingFolder); } + + // ── Docker Desktop internal distros (Parse filter) ───────────────────────────── + + [Fact] + public void Parse_FiltersDockerDesktopDistro() + { + const string raw = + " NAME STATE VERSION\n" + + "* Ubuntu Running 2\n" + + " docker-desktop Running 2\n"; + var result = WslDiscoveryService.Parse(raw); + Assert.Single(result); + Assert.Equal("Ubuntu", result[0].Name); + } + + [Fact] + public void Parse_FiltersDockerDesktopDataDistro_CaseInsensitive() + { + // Older Docker Desktop versions also install "docker-desktop-data"; casing is + // matched loosely since wsl -l -v's own casing isn't something we control. + const string raw = + " NAME STATE VERSION\n" + + " DOCKER-DESKTOP-DATA Running 2\n"; + var result = WslDiscoveryService.Parse(raw); + Assert.Empty(result); + } + + [Fact] + public void Parse_KeepsDistroNameThatOnlyContainsDockerDesktopPhrase() + { + // Exact match only — a user-imported distro that merely contains the phrase + // must still be offered in the picker. + const string raw = + " NAME STATE VERSION\n" + + " my-docker-desktop-clone Running 2\n"; + var result = WslDiscoveryService.Parse(raw); + Assert.Single(result); + Assert.Equal("my-docker-desktop-clone", result[0].Name); + } + + [Fact] + public void Parse_OnlyDockerDistros_YieldsEmptyList() + { + // So the dialog falls through to its existing "No WSL distros found" hint. + const string raw = + " NAME STATE VERSION\n" + + "* docker-desktop Running 2\n" + + " docker-desktop-data Stopped 2\n"; + var result = WslDiscoveryService.Parse(raw); + Assert.Empty(result); + } + + // ── GetLoginShellAsync ────────────────────────────────────────────────────────── + // Only the no-spawn short-circuit is testable without a live WSL distro (CI has + // none); the probe/cache path itself needs wsl.exe and is verified manually — see + // the fix report. + + [Fact] + public async Task GetLoginShellAsync_BlankDistro_ReturnsBashWithoutSpawning() + { + Assert.Equal("bash", await WslDiscoveryService.GetLoginShellAsync("")); + Assert.Equal("bash", await WslDiscoveryService.GetLoginShellAsync(" ")); + } } From 2ca903d066d164892b57ac1cb889e225656ab034 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Mon, 7 Sep 2026 20:53:40 +0200 Subject: [PATCH 44/45] fix(wsl): blank Linux folder means $HOME; unclip the dialog buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from manual testing. A blank Linux Working Folder is labelled "(optional)" but emitted no --cd at all, so wsl inherited the launching Windows process's cwd and the session landed in /mnt/c/Users/ on the 9p mount instead of $HOME. Emit `--cd ~` (unquoted) instead, and retry the $HOME probe at launch — the dialog's probe is capped at 3s and a cold distro blows through it, which left WorkingFolder at the distro root while the shell sat in $HOME. The New Session dialog is fixed-height with no scroll container, and WSL mode adds three rows the other modes lack; with the recently-closed list and custom args also showing, the content outgrew the window and the button row was pushed off the bottom edge. Dock the buttons and scroll the content. The shutdown board labelled rows with Command, which is now legitimately blank for a WSL session whose shell box was left empty. Use DisplayName. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- CLAUDE.md | 3 +- src/CodeShellManager/MainWindow.xaml.cs | 23 +++++++++++++- src/CodeShellManager/Models/ShellSession.cs | 10 ++++-- .../Views/NewSessionDialog.xaml | 31 +++++++++++++------ .../RunInstanceTests.cs | 6 ++-- .../ShellSessionTests.cs | 22 ++++++++++--- 6 files changed, 73 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a221487..c23e62e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -249,11 +249,12 @@ Remote sessions use the system `ssh` client as the PTY command — no extra libr ## WSL Sessions -`SessionKind.Wsl` launches `wsl.exe -d [-u ] [--cd ] -e -lc ""` (PR #65, hardened on `feat/wsl-sessions-v2`; `-e` replaced a bare `--` separator during the same hardening — see below). +`SessionKind.Wsl` launches `wsl.exe -d [-u ] --cd -e -lc ""` (PR #65, hardened on `feat/wsl-sessions-v2`; `-e` replaced a bare `--` separator during the same hardening — see below). - **`Kind` is the only persisted discriminator.** `IsRemote` is a `[JsonIgnore]`d two-way convenience over `Kind` (`false` on an SSH session makes it Local; a WSL session is untouched). The legacy `"IsRemote"` JSON key lands in `LegacyIsRemote` and `StateService.Normalize` folds it into `Kind` — migration lives in the loader, never in a setter. A promote-only setter was tried first and silently broke "Edit session" (SSH→Local could not demote) and then every save (`FullCommandLine` was serialised and threw); see `ShellSessionMigrationTests`. - **UNC mirror invariant.** A WSL session stores `WorkingFolder = WslDiscoveryService.ToUncPath(WslDistro, WslWorkingFolder)` (`\\wsl$\Ubuntu\home\alice\proj`). Explorer, the dormant row, run-command template seeding and `GitService` all work off that path unchanged. The Linux-side path lives on `WslWorkingFolder` and is what `--cd` receives. Every path that creates or edits a WSL session must keep both in step: `MainWindow` session creation, `InheritSessionKindFrom` (duplicate / worktree), `SessionConfigEditor.Apply`, `ReopenClosedSessionAsync`. - **`-e`, not `--`.** `wsl.exe -- …` runs the trailing command through the distro's *default* login shell before our own ` -lc "…"` ever sees it — a second, unwanted expansion pass in the wrong environment. Verified empirically: `wsl -d Ubuntu -- bash -lc 'for t in a b; do echo "L=$t"; done'` printed `L=` / `L=` (the loop variable never survived the first pass); the same command with `-e bash` printed `L=a` / `L=b`. `-e`/`--exec` runs the given program directly, skipping that pass, and composes fine with both `--cd` and `-u`. `--` looks more natural here — resist the urge to change it back. Any user command or run command containing `$var`, `` `…` ``, globs or `~` was silently mangled before this fix. +- **A blank Linux folder means `$HOME`, and `--cd` is always emitted.** The dialog labels the Linux Working Folder "(optional)", so blank has to mean something sensible. It now emits `--cd ~` (unquoted — quoting would make it a literal directory name), which is wsl.exe''s own spelling for the home directory and honours `-u`. Omitting `--cd` entirely, as this did before, does **not** do that: wsl inherits the launching *Windows* process''s cwd, so the session landed in `/mnt/c/Users/` on the slow 9p mount instead of `$HOME`. Separately, `NewSessionDialog` resolves `$HOME` eagerly to keep the UNC mirror in step, but that probe is capped at 3s and a cold distro (first launch after `wsl --install`) blows through it — leaving `WorkingFolder` at the distro root so git status and "Open in Explorer" aimed at `/` while the shell sat in `$HOME`. `LaunchSessionAsync` therefore retries the probe at launch, where the distro is starting anyway, and re-derives the UNC mirror via `ResyncWslWorkingFolder` when it lands (`GetDistroHomeAsync` caches only successes, so the retry really re-probes). - **Login-shell fallback (bash → sh).** `BuildWslArgs` no longer hardcodes `bash` as the login shell — minimal distros (Alpine, BusyBox images, Docker Desktop's own `docker-desktop` distro) have no bash, so hardcoding it failed *every* session and run command there, no matter what the user typed. `WslDiscoveryService.GetLoginShellAsync(distro, user)` probes `-e sh -c "command -v bash >/dev/null 2>&1 && echo bash || echo sh"` (mirrors `GetDistroHomeAsync`: cached per `(distro, user)`, 3s timeout, drains both stdout and stderr, never throws) and returns `"bash"` on any failure — that preserves prior behaviour rather than silently downgrading a distro that actually works. `MainWindow.LaunchSessionAsync` resolves it into the runtime-only `ShellSession.ResolvedWslShell` (`[JsonIgnore] internal`, never persisted — a distro's shells can change between runs) before calling `BuildWslArgs`, which uses `ResolvedWslShell ?? "bash"` for both the `-e ` login shell and the empty-`Command` fallback payload. Run commands share the resolved value for free since `RunInstance` reads the same `ShellSession` instance — no second probe. - **Docker-internal distros are filtered from the picker.** `WslDiscoveryService.Parse` drops `docker-desktop` and `docker-desktop-data` (exact, case-insensitive match — a name that merely *contains* the phrase, e.g. `my-docker-desktop-clone`, is kept) before the listing reaches `NewSessionDialog`. Those are Docker's own BusyBox-based plumbing — root-only, rebuilt on Docker updates, not a user environment — and offering them (often as the *only* entry on a dev machine) is how a maintainer here hit the bash-not-found bug during manual testing. A listing containing only Docker distros now returns empty, so the dialog falls through to its existing "No WSL distros found" hint. - **GitService routing.** `RunGitFullAsync` detects the UNC and runs `wsl.exe -d -- git -C …`, translating `\\wsl$` args to Linux (`TranslateUncArgsToLinux`, with a distro-name boundary so `Ubuntu` never matches `Ubuntu-22.04`) and Linux paths in stdout back to UNC. This path still uses `--` deliberately, not `-e`: `git` is invoked with a literal argv, not a shell-interpreted string payload, so there is no second expansion pass to avoid. `SessionViewModel.RefreshGitInfoAsync` runs the probe on the thread pool (a `Directory.Exists` on `\\wsl$` boots a stopped distro and used to freeze the UI), polls WSL sessions every **30 s** (10 s local) and caches a "not a repo" answer for WSL so it does not spawn `wsl.exe` for it forever. diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 91986d9..b4460ac 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -458,7 +458,10 @@ private void BuildShutdownBoard(IReadOnlyList sessions) var name = new TextBlock { - Text = string.IsNullOrWhiteSpace(vm.Name) ? vm.Command : vm.Name, + // DisplayName, not Command: a WSL session can legitimately carry a blank + // Command (the dialog stores "" when the shell box is empty, so the login + // shell is resolved at launch), which used to render an empty row label. + Text = vm.DisplayName, Foreground = new SolidColorBrush(Color.FromRgb(0x6c, 0x70, 0x86)), FontFamily = new FontFamily("Segoe UI"), FontSize = 11.5, @@ -1281,6 +1284,24 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal // every launch there. Run commands inherit this via the same ShellSession // instance (RunInstance.BuildWslArgs delegates to session.BuildWslArgs). session.ResolvedWslShell = await WslDiscoveryService.GetLoginShellAsync(session.WslDistro, session.WslUser); + + // The dialog resolves $HOME eagerly when the Linux folder is left blank, but that + // probe is capped at 3s and a cold distro (first launch after install) blows + // through it — leaving the folder blank and WorkingFolder pointing at the distro + // ROOT, so git status, the sidebar subtitle and "Open in Explorer" all aimed at / + // while the shell itself sat in $HOME. Retry here, where the distro is being + // started anyway, and re-derive the UNC mirror when it lands. GetDistroHomeAsync + // only caches successes, so this really does re-probe. + if (string.IsNullOrWhiteSpace(session.WslWorkingFolder)) + { + string? home = await WslDiscoveryService.GetDistroHomeAsync(session.WslDistro, session.WslUser); + if (!string.IsNullOrEmpty(home)) + { + session.WslWorkingFolder = home; + WslDiscoveryService.ResyncWslWorkingFolder(session); + _ = _vm.SaveStateAsync(); + } + } } var vm = new SessionViewModel(session); diff --git a/src/CodeShellManager/Models/ShellSession.cs b/src/CodeShellManager/Models/ShellSession.cs index 7061bcb..25e2c6c 100644 --- a/src/CodeShellManager/Models/ShellSession.cs +++ b/src/CodeShellManager/Models/ShellSession.cs @@ -273,8 +273,14 @@ internal string BuildWslArgs(string? inner = null) sb.Append("-d ").Append(QuoteForCmd(WslDistro)); if (!string.IsNullOrWhiteSpace(WslUser)) sb.Append(" -u ").Append(QuoteForCmd(WslUser)); - if (!string.IsNullOrWhiteSpace(WslWorkingFolder)) - sb.Append(" --cd ").Append(QuoteForCmd(WslWorkingFolder)); + // A blank folder means "the user's home" — that is what the dialog's "(optional)" + // label promises. `--cd ~` is wsl.exe's own spelling for it and honours -u. Omitting + // --cd entirely does NOT do this: wsl then inherits the launching *Windows* process's + // cwd and lands the session in /mnt/c/... on the slow 9p mount. Pass ~ unquoted; + // quoting it would make it a literal directory name. + sb.Append(" --cd ").Append(string.IsNullOrWhiteSpace(WslWorkingFolder) + ? "~" + : QuoteForCmd(WslWorkingFolder)); if (inner is null) { var shell = string.IsNullOrWhiteSpace(Command) ? loginShell : Command; diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml b/src/CodeShellManager/Views/NewSessionDialog.xaml index 24b6a9e..c7f2b8c 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml @@ -1,7 +1,7 @@ @@ -143,7 +143,23 @@ - + + + +