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..449e90a 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 }; @@ -455,12 +456,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. @@ -494,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; @@ -569,6 +590,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; @@ -603,14 +625,7 @@ private async Task DuplicateSessionAsync(SessionViewModel parent) string.IsNullOrEmpty(p.GroupId) ? null : p.GroupId, colorOverride: null, afterSessionId: parent.Id); - if (p.IsRemote) - { - clone.IsRemote = true; - clone.SshUser = p.SshUser; - clone.SshHost = p.SshHost; - clone.SshPort = p.SshPort; - clone.SshRemoteFolder = p.SshRemoteFolder; - } + InheritSessionKindFrom(clone, p); clone.ProfileFontFamily = p.ProfileFontFamily; clone.ProfileFontSize = p.ProfileFontSize; clone.ProfileFontWeight = p.ProfileFontWeight; @@ -655,6 +670,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. @@ -676,6 +740,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; @@ -698,7 +763,12 @@ private async Task LaunchSessionInSiblingWorktreeAsync(SessionViewModel parent, /// private void SeedRunCommandsAsync(Models.ShellSession session) { - if (session.IsRemote) 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; @@ -1038,12 +1108,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) @@ -2749,6 +2828,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()); @@ -2936,6 +3022,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; @@ -4157,9 +4244,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 +4256,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 +4333,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 +4345,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 +4433,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 ──────────────────────────────────────────────────────────────── @@ -4550,6 +4622,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 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, + }; } diff --git a/src/CodeShellManager/Models/ShellSession.cs b/src/CodeShellManager/Models/ShellSession.cs index de82a4b..f93e5b0 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,44 @@ 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; } + /// + /// 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; } + } + + /// 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; 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 +104,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 +135,93 @@ 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. 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 {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("\"", "\\\"")}\""); + 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) ──── + + /// + /// 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}: {System.IO.Path.GetFileName(WslWorkingFolder.TrimEnd('/'))}"), + _ => 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 ""; + // 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}"; + } } diff --git a/src/CodeShellManager/Services/GitService.cs b/src/CodeShellManager/Services/GitService.cs index 9d50ff4..39108b5 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,125 @@ 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); + // 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 {Models.ShellSession.QuoteForCmd(distro)} -- git -C {Models.ShellSession.QuoteForCmd(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; + 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 => + { + 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; + } + + /// + /// 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; + // 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('/', '\\'); + return m.Groups[1].Value + unc; + }, RegexOptions.Multiline); + } } diff --git a/src/CodeShellManager/Services/RunInstance.cs b/src/CodeShellManager/Services/RunInstance.cs index f2dbe12..9204516 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,31 @@ 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 {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(); + } + /// /// 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..884f283 --- /dev/null +++ b/src/CodeShellManager/Services/WslDiscoveryService.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +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 (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(); + } + } + + /// + /// 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); + + bool isDefault = tokens.Length > 0 && tokens[0] == "*"; + int firstNameIdx = isDefault ? 1 : 0; + + // `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)); + } + // Stable ordering: default first, then alphabetical. + return results + .OrderByDescending(d => d.IsDefault) + .ThenBy(d => d.Name, StringComparer.OrdinalIgnoreCase) + .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 + { + // 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") + { + 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; + + // 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 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; + + string home = outTask.Result.Trim(); + if (string.IsNullOrEmpty(home)) return null; + lock (_homeCache) _homeCache[key] = home; + return home; + } + catch (Exception) { 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 + /// 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..d04f8e2 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,11 @@ 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; 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; GitIsDirty = isDirty; diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml b/src/CodeShellManager/Views/NewSessionDialog.xaml index 1d5b6da..f35d6bd 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,51 @@ ToolTip="e.g. /home/alice/project — leave blank for home directory"/> + + + + + + + + + + + + + + + + + + + + + + + +