Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2b2d7ad
feat(sessions): first-class WSL session type alongside Local and SSH
Bitblade May 17, 2026
60de1ab
feat(new-session): folder picker for WSL working folder
Bitblade May 17, 2026
4a49953
feat(sessions): add IsWsl convenience predicate on ShellSession
Bitblade May 17, 2026
bc4b3e6
fix(git): route GitService through wsl.exe for WSL UNC working folders
Bitblade May 17, 2026
fe2b3d0
feat(new-session): WSL browse picker opens at the distro's home folder
Bitblade May 17, 2026
ad657d1
fix(new-session): re-fire name suggestion when distro / folder changes
Bitblade May 17, 2026
e82f6ae
feat(new-session): inherit WSL distro/user/folder from the parent ses…
Bitblade May 17, 2026
736dca4
feat(menu): "Open WSL console here" for WSL sessions
Bitblade May 17, 2026
96003de
fix(worktree): new worktree sessions inherit kind from the parent
Bitblade May 17, 2026
86b73ff
fix(new-session): drop SelectedPath so the WSL picker's Folder field …
Bitblade May 17, 2026
bf8011f
fix(wsl): drain stderr in GetDistroHomeAsync to avoid pipe-buffer dea…
Bitblade May 17, 2026
6c93b67
docs(session-vm): update stale comment on WSL git refresh
Bitblade May 17, 2026
b26accd
feat(run-commands): allow template seeding for WSL sessions
Bitblade May 17, 2026
ef4c73e
fix(recents): persist Kind + WSL fields so reopened WSL sessions stay…
Bitblade May 17, 2026
ae34fbb
fix(git): TranslateUncArgsToLinux handles quoted UNC paths with spaces
Bitblade May 17, 2026
9c2ac28
fix(args): quote distro/user/cwd values in WSL arg builders
Bitblade May 17, 2026
201d61e
fix(new-session): reject non-WSL paths from the WSL folder picker
Bitblade May 17, 2026
dfc0293
fix(new-session): resolve \$HOME eagerly when WslWorkingFolder is blank
Bitblade May 17, 2026
73694d0
fix(wsl): honor "Never throws" contract on discovery helpers
Bitblade May 17, 2026
08140e3
fix(wsl): Parse handles distro names with spaces
Bitblade May 17, 2026
564a773
fix(wsl): QuoteForCmd the distro/user in GetDistroHomeAsync too
Bitblade May 17, 2026
c7ef555
fix(run-commands): switch WSL run-args to Windows-style double quotes
Bitblade May 17, 2026
e0044f0
fix(git): TranslateLinuxPathsToUnc allows spaces in path tail
Bitblade May 17, 2026
a376a7a
refactor(paths): use Path.GetFileName instead of hand-rolled LeafName
Bitblade May 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cmd>…` or `Connecting to <host>…`) 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$\<distro>` 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
Expand Down
159 changes: 126 additions & 33 deletions src/CodeShellManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down Expand Up @@ -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);
Comment thread
Bitblade marked this conversation as resolved.
}

// 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.
Expand Down Expand Up @@ -494,11 +507,19 @@ private async Task<ShellSession> 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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -655,6 +670,55 @@ private string DeriveDuplicateName(string baseName)
return $"{stem} ({start})";
}

/// <summary>
/// Propagates a parent session's <see cref="Models.SessionKind"/> and kind-specific
/// fields (SSH host/user/port, WSL distro/user) onto a freshly-created child
/// session. For WSL children it also derives <c>WslWorkingFolder</c> from the
/// child's <c>WorkingFolder</c>, which the worktree code paths set to a
/// <c>\\wsl$\&lt;distro&gt;\…</c> UNC. Without this step a new session spawned
/// from a WSL parent (Duplicate, sibling worktree, new worktree) silently falls
/// back to <see cref="Models.SessionKind.Local"/> and tries to run the parent's
/// command (e.g. <c>claude</c>) inside a Windows PowerShell at the UNC path.
/// </summary>
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;
}
}
}

/// <summary>
/// Launches a new session in an existing sibling worktree (path resolved via
/// `git worktree list`). Inherits the source session's command, group, and profile.
Expand All @@ -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;
Expand All @@ -698,7 +763,12 @@ private async Task LaunchSessionInSiblingWorktreeAsync(SessionViewModel parent,
/// </summary>
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$\<distro>\…` 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;

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
{
Expand Down Expand Up @@ -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
Expand All @@ -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
{
Expand Down Expand Up @@ -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 ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -4550,6 +4622,27 @@ private void LaunchPowerShellInFolder(string workingFolder, string groupId)
_ = LaunchSessionAsync(session);
}

/// <summary>
/// WSL counterpart of <see cref="LaunchPowerShellInFolder"/>: spawns a bare bash
/// session inside the same distro + Linux folder as <paramref name="parent"/>.
/// Used by the "Open WSL console here" context-menu item.
/// </summary>
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
Expand Down
36 changes: 31 additions & 5 deletions src/CodeShellManager/Models/RecentlyClosedEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,29 @@ public class RecentlyClosedEntry
public string GroupId { get; set; } = "";
public string? ColorOverride { get; set; }

public bool IsRemote { get; set; }
/// <summary>
/// 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
/// <see cref="ShellSession.IsRemote"/> migration: setting <see cref="IsRemote"/>
/// to true promotes <c>Local → Ssh</c>, so legacy state.json entries (which
/// only carried IsRemote) still display the right subtitle and reopen as SSH.
/// </summary>
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; }
Expand Down Expand Up @@ -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,
Expand All @@ -84,8 +105,13 @@ public class RecentlyClosedEntry
ClosedAt = DateTime.UtcNow,
};

/// <summary>Friendly subtitle for the recents UI — folder or user@host.</summary>
public string Subtitle => IsRemote
? (string.IsNullOrWhiteSpace(SshUser) ? SshHost : $"{SshUser}@{SshHost}")
: WorkingFolder;
/// <summary>Friendly subtitle for the recents UI — kind-specific locator.</summary>
public string Subtitle => Kind switch
{
SessionKind.Ssh => string.IsNullOrWhiteSpace(SshUser) ? SshHost : $"{SshUser}@{SshHost}",
SessionKind.Wsl => string.IsNullOrEmpty(WslWorkingFolder)
? WslDistro
: $"{WslDistro}: {WslWorkingFolder}",
_ => WorkingFolder,
};
}
Loading