feat: first-class WSL session support (supersedes #65) - #121
Merged
Conversation
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$\<distro>\...` 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 '<escaped>'`. 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.
Mirrors the local Browse… button on the WSL panel. Opens FolderBrowserDialog rooted at \\wsl$\<selected-distro> (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.
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`.
Git for Windows can't reliably operate on \\wsl$\<distro>\... 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 <distro> -- git -C <linuxPath> <args>`
sidesteps both. Two small translators make the seam invisible:
TranslateUncArgsToLinux: rewrites \\wsl$\<distro>\foo tokens in the
arg string to /foo before invocation, so callers can keep passing
Windows-shaped paths (e.g. `worktree add <unc-target>`).
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.
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 <distro> [-u <user>] -- 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.
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.
…sion 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.
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.
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$\<distro>\.... 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.
…starts clean 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.
…dlock 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.
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.
The previous guard "Kind != Local → return" was too conservative — RunCommandTemplatesService.SeedFor calls Directory.EnumerateFiles, which works fine on `\\wsl$\<distro>\…` 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.
… WSL 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 `<distro>: <path>`. Per Copilot review.
The unquoted regex stops at whitespace, so a quoted UNC like "\\wsl\$\Ubuntu\home\alice\my repo" (the shape `worktree add <target>` 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.
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.
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.
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\$\<distro>` — 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.
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).
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).
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).
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.
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.
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.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
…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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
…e only Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
…est case Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
…unch Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
… 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
…lator 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
… 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
…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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
10 tasks
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 <shell>` (--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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
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/<you> 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
Review follow-ups. The login-shell and $HOME probes each carry a 3s timeout and are independent, so running them in sequence doubled the worst case on a cold launch, once per session during a restore. Start the shell probe first and await it last. wsl.exe special-cases only the bare `~` token: a folder typed as "~/proj" is treated as an absolute Windows path and fails the launch outright. Expand it against $HOME on the same path that already resolves a blank folder. Also drops two Grid rows left unused when the button row moved into the DockPanel, and corrects two doubled apostrophes in CLAUDE.md. The docs now record that a bare `~` cannot be tested from PowerShell, which expands it to the Windows profile path before wsl.exe sees it. Verified from cmd: `--cd ~` gives /home/<user>, and with `-u root` gives /root. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
This was referenced Sep 7, 2026
AThraen
added a commit
that referenced
this pull request
Sep 8, 2026
…ently origin/main landed #121, and 55609f0 within it independently addressed the same root cause from the other end. Neither side is redundant, so both are kept. **Their half** wrapped the two call sites in SessionViewModel with Task.Run, which moves GitService's synchronous prefix — Directory.Exists, and on a \wsl$ share that boots a stopped distro — off the dispatcher. Plus a WSL-specific 30s cadence, a negative repo-root cache so a not-a-repo answer stops costing a wsl.exe spawn per tick, and a cancellation guard so a probe finishing after Dispose can't touch a dead VM. **My half** hardens GitService itself: RunGitFullAsync is Task.Run-wrapped and every await is ConfigureAwait(false), so *every* caller is safe, not just the two that were patched. ListWorktreesAsync, ListBranchesAsync and CreateWorktreeAsync are all reachable from UI code and all still had Process.Start on the caller's thread. Reconciliations that needed a decision rather than a pick: - **Dropped my Task.Run around the constructor's poll start.** It existed to prevent the UI SynchronizationContext being captured, but with GitService hardened nothing dangerous runs on that thread any more, and dropping the capture would have moved every property set off the UI thread — losing the invariant their comment is explicit about. Capturing the context is correct again now that the thing which made it harmful is gone. - **GitRepoWatcher fires on a threadpool thread**, which would have broken that same invariant from the other direction. It now posts to the SynchronizationContext captured at construction, so the watcher path sets properties on the same thread the poll path does. - **Merged the two cadence policies into one function.** GitPollIntervalFor now takes (kind, isForeground): background 120s regardless of kind, foreground 10s local / 30s WSL. Their kind axis and my foreground axis are answering different questions and compose cleanly. - **The watcher is Local-only.** A WSL session's folder is a \wsl$ UNC, and watching that keeps the distro's 9p server busy — defeating exactly the idle-VM shutdown their 30s cadence protects. - ApplyGitInfo now sits behind their cancellation guard, so the coalesced notification inherits the disposed-VM protection. 495/495 tests, 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Lands first-class WSL sessions (#65 by @Bitblade — thank you!) on top of current
main, with every finding from the review of #65 fixed and the newer "Edit session" feature (#98) taught about WSL. Supersedes #65; the contributor's 24 commits are preserved verbatim underneath the hardening commits.WSL becomes a third
SessionKindalongside Local and SSH: the New Session dialog gains a WSL radio with distro auto-detection, a Linux working folder (browse rooted at\\wsl$\<distro>\~), and an optional-u <user>; the session runswsl.exe -d <distro> [-u <user>] --cd <folder> -- bash -lc "<cmd>". Git status, repo-root detection, worktrees, run commands, sleep/wake, recently-closed and relaunch-from-search all work for WSL sessions.What the review of #65 found, and what changed
IsRemotesetter ignoredfalse, so editing an SSH session to Local keptKind=Ssh; the serialisedFullCommandLinethen threw on everystate.jsonsaveLegacyIsRemoteJSON slot +MigrateLegacyFields()called fromStateService.Normalize;IsRemoteis a[JsonIgnore]d two-way convenience;FullCommandLinenever throws."IsRemote": trueis still written for SSH sessions so a rollback to a pre-Kindrelease keeps them SSHShellSessionMigrationTestsSessionConfigDraft,SessionConfigEditorknew nothing about WSLKind+WslDistro/WslUser/WslWorkingFolder;Applyre-derives the\\wsl$UNCWorkingFolder; dialog pre-fills WSL mode, keeps an uninstalled distro as(not installed)in edit mode onlySessionConfigEditorTestssession_history.snapshot_jsonstores aRecentlyClosedEntry; relaunch goes throughReopenClosedSessionAsync; existing DBs upgraded idempotentlySearchServiceTestsDirectory.Existson\\wsl$on the UI thread boots a stopped distro and froze the appQuoteForCmd, oneBuildWslArgsshared by sessions and run commandsWin32CommandLineTestsround-trips through the realCommandLineToArgvWStart_Clickre-entrancy during the 3 s WSL home probeUbuntumatchedUbuntu-22.04in the UNC→Linux arg translatorWslDiscoveryService.TryParseUncPathreplaces two parsersGitServiceWslRoutingTests,WslDiscoveryServiceTestswsl.exeup to 3× every 10 s foreverSessionViewModelGitPollingTestsBuildWslArgsafter the pane existed, leaking itShellSession.LaunchValidationErrorchecked before any UI;RunInstance.Startfails as a run chip instead of throwingShellSessionTests,SessionRunnerTestsKindreplacesIsRemoteas the documented branch pointWhole-branch review
A final review of the merged branch raised two more items, both fixed here:
RunInstancewas copying its full 1 MB output buffer on every 4 KB PTY chunk for a property nothing reads (hot-path regression introduced while extracting a helper), and the launch-refusal dialog renderedCannot start ''.for the default blank session name — the one message on a path that deletes a restored session. Also folded in: a single shared helper that re-derives the\wsl$UNC soReopenClosedSessionAsync,SessionConfigEditor.Applyand the creation paths cannot drift; a null-distro guard in the UNC parser; NUL-safeFolderShort; and the New Session dialog no longer serializes thewsl -l -vspawn ahead of the worktree probe.Test plan
dotnet build -c Release— 0 warningsdotnet test tests/CodeShellManager.Tests/— 457 passed, 0 failed (400 onmain)state.jsonfrom v0.7.0 withIsRemote: trueloads as SSHKnown gaps (documented in CLAUDE.md)
--resumeid lookup reads the Windows~/.claude).-is not defended against inwsl.exeoption parsing.🤖 Generated with Claude Code
https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu
Found in manual testing (after the whole-branch review)
Testing on a machine whose only WSL distro was
docker-desktopsurfaced four more, all fixed here:wsl.exewas double-expanding every payload.wsl <cmd>runs the trailing command through the distro's default login shell, so-- bash -lc "…"got expanded twice, in the wrong environment.wsl -d Ubuntu -- bash -lc 'for t in a b; do echo "L=$t"; done'printedL=twice; with-eit printsL=a/L=b. Any command containing a variable, subshell, glob or tilde was silently wrong — on every distro, not just unusual ones.bashwas hardcoded. Distros without it (Alpine, BusyBox images,docker-desktop) could not start a session or a run command at all, whatever the user typed. The login shell is now probed once per distro and falls back tosh.--cdwas emitted, so the session inherited the launching Windows process's directory and landed in/mnt/c/Users/<you>on the 9p mount. It now uses--cd ~, a typed~/projis expanded first, and the$HOMEprobe retries at launch because its 3s cap expires on a cold distro.