diff --git a/CLAUDE.md b/CLAUDE.md index 33c2e95..d100f1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,6 +79,7 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js) | `PaddingParser` | WT `padding` shorthand (1/2/4 comma ints) → CSS `Npx` shorthand | | `CommandLineSplitter` | Helper — quote-aware split of a Windows commandline into `(exe, args)` | | `ShellIntegrationPayload` | WPF-free validation for the OSC 9001 channel: hex-colour check + `#rrggbbaa`→`#aarrggbb`, dirty-flag parse, title/branch sanitising (control chars stripped, 80-char cap). See "Shell Integration (OSC 9001)" | +| `WslDiscoveryService` | `wsl -l -v` parsing (UTF-16, header skipped, `*` default marker, names with spaces, Docker-internal distros filtered), `GetDistroHomeAsync` (cached `cd ~ && pwd` per distro+user), `GetLoginShellAsync` (cached `bash`-or-`sh` probe per distro+user, defaults to `bash` on any failure), `ToUncPath` / `TryParseUncPath` — the **only** UNC↔Linux path converters; GitService and NewSessionDialog delegate here | ## Project Structure @@ -128,7 +129,7 @@ tests/ - Accent blue: `#89b4fa`, Green: `#a6e3a1`, Alert pink: `#f38ba8` - Hover: `#45475a`, Selected: `#585b70` -**Session accent colors** — `ColorService.GetHexColor(key)` uses FNV-1a hash to deterministically assign one of 12 colors. For local sessions the key is `WorkingFolder`; for SSH sessions it is `user@host`. Used as sidebar stripe + terminal toolbar top border. +**Session accent colors** — `ColorService.GetHexColor(key)` uses FNV-1a hash to deterministically assign one of 12 colors. For local sessions the key is `WorkingFolder`; for SSH sessions `user@host`; for WSL `wsl://`. Used as sidebar stripe + terminal toolbar top border. **Active-terminal highlight** — every terminal pane is wrapped in an outer "active ring" Border (constant 2px thickness, transparent by default) so toggling it doesn't shift content. `UpdateActiveTerminalHighlight` (called from `UpdateSidebarActiveState`, which fires on every `MainViewModel.ActiveSession` change) paints the ring of the active session's pane in its accent color and clears all others. @@ -151,8 +152,8 @@ The page-side `mousedown` handler also calls `fitAddon.fit()`, and the initial f ## Session Lifecycle -1. User clicks **+ New Session** → `NewSessionDialog` modal (Local or Remote SSH) -2. `SessionManager.CreateSession()` creates `ShellSession` model; caller copies SSH fields if remote +1. User clicks **+ New Session** → `NewSessionDialog` modal (Local, Remote SSH, or WSL) +2. `SessionManager.CreateSession()` creates `ShellSession` model; caller sets `Kind` and copies SSH or WSL fields; for WSL it also sets `WorkingFolder` to the `\\wsl$\\` UNC mirror (see "WSL Sessions") 3. `LaunchSessionAsync()` creates: `SessionViewModel` → `WebView2` → `TerminalBridge` → `PseudoTerminal` 4. `OutputIndexer` indexes all output to SQLite; `AlertDetector` watches for prompts 5. Termination paths: @@ -181,32 +182,40 @@ dormant row (edit a sleeping session without waking it). - Title becomes "Edit Session", the primary button becomes "Save". - "Recently closed" and the sibling-worktree checkbox list are hidden (both are create-only concepts); the worktree probe is skipped entirely (`IsEditMode` guard). -- Local/Remote radio, folder, SSH host/port/remote folder, command (matched against the - launch-command list, falling back to `[custom]`), and name are all pre-filled. +- Local/Remote/WSL radio, folder, SSH host/port/remote folder, WSL distro (pre-selected once + the async list loads; a since-uninstalled distro is kept as a `(not installed)` entry so + Save cannot wipe it), WSL user and Linux folder are all pre-filled, as is command (matched + against the launch-command list, falling back to `[custom]`), and name. - **Appearance combobox in edit mode:** when the session already carries profile overrides, the list gains a `— keep current appearance —` entry (tag `KeepCurrentAppearanceTag`, selected by default so saving never silently resets the look) and the old `— none —` entry is relabelled `— clear appearance overrides —`. The panel is shown when there are profiles to pick **or** overrides to clear, so overrides remain removable with Windows - Terminal import switched off. + Terminal import switched off. The appearance panel is shown for Local and WSL, hidden only + for SSH. **Result plumbing** — the dialog's `ToDraft()` returns a `Models.SessionConfigDraft` (a flat, UI-free snapshot of every field the form owns). `Services.SessionConfigEditor` then does the work, and being WPF-free is what makes the rules unit-testable (`tests/CodeShellManager.Tests/SessionConfigEditorTests.cs`): -- `Diff(session, draft)` → `SessionConfigChange(AnyChange, RequiresRelaunch, WorkingFolderChanged, AppearanceChanged)` +- `Diff(session, draft)` → `SessionConfigChange(AnyChange, RequiresRelaunch, WorkingFolderChanged, AppearanceChanged)`. + `WorkingFolderChanged` also fires for a WSL distro/Linux-folder change, not just a local + folder edit. - `Apply(session, draft)` writes every form-owned field verbatim (blanks included, so - clearing in the dialog really clears). Runtime state — `Id`, `GroupId`, `Status`, + clearing in the dialog really clears), including `Kind` directly; for WSL it also + **re-derives the UNC `WorkingFolder`** from the saved distro + Linux folder rather than + trusting whatever the dialog had cached. Runtime state — `Id`, `GroupId`, `Status`, `RunCommands`, `IsDormant` — is untouched. -**What needs a restart.** `RequiresRelaunch` is true for: local↔remote flip, command or +**What needs a restart.** `RequiresRelaunch` is true for: any `Kind` change, command or args change, working-folder change (path-normalized compare), any SSH target field change, -crossing the transparency boundary (opacity `< 1.0` picks a different xterm host page at -navigation time), and *clearing* an override (`TerminalBridge.ApplyProfileOverrides` only -ever **sets** options, so it can't push a value back to the global default). SSH fields and -the working folder are only compared while the session stays in the same mode, so leftovers -from a previous mode don't read as a change. +any WSL field change (distro, user, Linux folder), crossing the transparency boundary +(opacity `< 1.0` picks a different xterm host page at navigation time), and *clearing* an +override (`TerminalBridge.ApplyProfileOverrides` only ever **sets** options, so it can't push +a value back to the global default). SSH and WSL fields and the working folder are only +compared while the session stays in the same kind, so leftovers from a previous mode don't +read as a change. Everything else is applied live: `SessionViewModel.NotifyConfigChanged()` re-raises the model-mirroring properties so the sidebar row and terminal toolbar repaint in place (no @@ -228,14 +237,32 @@ Run commands are *not* part of this form; they have their own editor Remote sessions use the system `ssh` client as the PTY command — no extra library. -- `ShellSession.IsRemote` flag distinguishes remote from local sessions +- `ShellSession.Kind == SessionKind.Ssh` distinguishes remote sessions from Local/WSL ones. + `IsRemote` still exists as a `[JsonIgnore]`d two-way convenience view over `Kind` for the + SSH case (not the persisted discriminator — see "WSL Sessions"). - SSH config fields on `ShellSession`: `SshUser`, `SshHost`, `SshPort` (default 22), `SshRemoteFolder` - `ShellSession.BuildSshArgs()` (internal) produces: `-t [–p PORT] user@host "cd 'folder' && shell"` -- `LaunchSessionAsync()` branches on `IsRemote`: uses `ssh` + `BuildSshArgs()`, skips Claude auto-resume +- `LaunchSessionAsync()` branches on `Kind`: SSH sessions use `ssh` + `BuildSshArgs()`, skipping Claude auto-resume - `PseudoTerminal.BuildCmdLine` passes `ssh` through directly (same as `cmd`/`pwsh`) — not wrapped in PowerShell - `SessionViewModel.RefreshGitInfoAsync()` early-returns for remote sessions (no local working folder) - SSH fields serialize to `state.json` automatically — sessions restore and relaunch on next startup +## WSL Sessions + +`SessionKind.Wsl` launches `wsl.exe -d [-u ] --cd -e -lc ""` (PR #65, hardened on `feat/wsl-sessions-v2`; `-e` replaced a bare `--` separator during the same hardening — see below). + +- **`Kind` is the only persisted discriminator.** `IsRemote` is a `[JsonIgnore]`d two-way convenience over `Kind` (`false` on an SSH session makes it Local; a WSL session is untouched). The legacy `"IsRemote"` JSON key lands in `LegacyIsRemote` and `StateService.Normalize` folds it into `Kind` — migration lives in the loader, never in a setter. A promote-only setter was tried first and silently broke "Edit session" (SSH→Local could not demote) and then every save (`FullCommandLine` was serialised and threw); see `ShellSessionMigrationTests`. +- **UNC mirror invariant.** A WSL session stores `WorkingFolder = WslDiscoveryService.ToUncPath(WslDistro, WslWorkingFolder)` (`\\wsl$\Ubuntu\home\alice\proj`). Explorer, the dormant row, run-command template seeding and `GitService` all work off that path unchanged. The Linux-side path lives on `WslWorkingFolder` and is what `--cd` receives. Every path that creates or edits a WSL session must keep both in step: `MainWindow` session creation, `InheritSessionKindFrom` (duplicate / worktree), `SessionConfigEditor.Apply`, `ReopenClosedSessionAsync`. +- **`-e`, not `--`.** `wsl.exe -- …` runs the trailing command through the distro's *default* login shell before our own ` -lc "…"` ever sees it — a second, unwanted expansion pass in the wrong environment. Verified empirically: `wsl -d Ubuntu -- bash -lc 'for t in a b; do echo "L=$t"; done'` printed `L=` / `L=` (the loop variable never survived the first pass); the same command with `-e bash` printed `L=a` / `L=b`. `-e`/`--exec` runs the given program directly, skipping that pass, and composes fine with both `--cd` and `-u`. `--` looks more natural here — resist the urge to change it back. Any user command or run command containing `$var`, `` `…` ``, globs or `~` was silently mangled before this fix. +- **A blank Linux folder means `$HOME`, and `--cd` is always emitted.** The dialog labels the Linux Working Folder "(optional)", so blank has to mean something sensible. It now emits `--cd ~` (unquoted — quoting would make it a literal directory name), which is wsl.exe's own spelling for the home directory and honours `-u` (`-u root --cd ~` lands in `/root`). **Testing this from PowerShell will mislead you:** PowerShell expands a bare `~` to the *Windows* profile path before wsl.exe sees it, so you get `/mnt/c/Users/` and conclude the flag is broken — a review of this very change did exactly that. Quote it, or test from `cmd`. Our own call passes the argument string straight to `CreateProcess` with no shell in between, so it behaves like the `cmd` case. A folder typed as `~/proj` is expanded by `LaunchSessionAsync` before it reaches wsl.exe, which special-cases only the bare `~` token and would otherwise treat `~/proj` as an absolute Windows path and fail the launch. Omitting `--cd` entirely, as this did before, does **not** do that: wsl inherits the launching *Windows* process's cwd, so the session landed in `/mnt/c/Users/` on the slow 9p mount instead of `$HOME`. Separately, `NewSessionDialog` resolves `$HOME` eagerly to keep the UNC mirror in step, but that probe is capped at 3s and a cold distro (first launch after `wsl --install`) blows through it — leaving `WorkingFolder` at the distro root so git status and "Open in Explorer" aimed at `/` while the shell sat in `$HOME`. `LaunchSessionAsync` therefore retries the probe at launch, where the distro is starting anyway, and re-derives the UNC mirror via `ResyncWslWorkingFolder` when it lands (`GetDistroHomeAsync` caches only successes, so the retry really re-probes). +- **Login-shell fallback (bash → sh).** `BuildWslArgs` no longer hardcodes `bash` as the login shell — minimal distros (Alpine, BusyBox images, Docker Desktop's own `docker-desktop` distro) have no bash, so hardcoding it failed *every* session and run command there, no matter what the user typed. `WslDiscoveryService.GetLoginShellAsync(distro, user)` probes `-e sh -c "command -v bash >/dev/null 2>&1 && echo bash || echo sh"` (mirrors `GetDistroHomeAsync`: cached per `(distro, user)`, 3s timeout, drains both stdout and stderr, never throws) and returns `"bash"` on any failure — that preserves prior behaviour rather than silently downgrading a distro that actually works. `MainWindow.LaunchSessionAsync` resolves it into the runtime-only `ShellSession.ResolvedWslShell` (`[JsonIgnore] internal`, never persisted — a distro's shells can change between runs) before calling `BuildWslArgs`, which uses `ResolvedWslShell ?? "bash"` for both the `-e ` login shell and the empty-`Command` fallback payload. Run commands share the resolved value for free since `RunInstance` reads the same `ShellSession` instance — no second probe. +- **Docker-internal distros are filtered from the picker.** `WslDiscoveryService.Parse` drops `docker-desktop` and `docker-desktop-data` (exact, case-insensitive match — a name that merely *contains* the phrase, e.g. `my-docker-desktop-clone`, is kept) before the listing reaches `NewSessionDialog`. Those are Docker's own BusyBox-based plumbing — root-only, rebuilt on Docker updates, not a user environment — and offering them (often as the *only* entry on a dev machine) is how a maintainer here hit the bash-not-found bug during manual testing. A listing containing only Docker distros now returns empty, so the dialog falls through to its existing "No WSL distros found" hint. +- **GitService routing.** `RunGitFullAsync` detects the UNC and runs `wsl.exe -d -- git -C …`, translating `\\wsl$` args to Linux (`TranslateUncArgsToLinux`, with a distro-name boundary so `Ubuntu` never matches `Ubuntu-22.04`) and Linux paths in stdout back to UNC. This path still uses `--` deliberately, not `-e`: `git` is invoked with a literal argv, not a shell-interpreted string payload, so there is no second expansion pass to avoid. `SessionViewModel.RefreshGitInfoAsync` runs the probe on the thread pool (a `Directory.Exists` on `\\wsl$` boots a stopped distro and used to freeze the UI), polls WSL sessions every **30 s** (10 s local) and caches a "not a repo" answer for WSL so it does not spawn `wsl.exe` for it forever. +- **Quoting.** Everything that reaches `wsl.exe` goes through `ShellSession.QuoteForCmd` — MSVCRT rules (backslashes before a quote doubled, trailing backslashes doubled), verified by round-tripping through `CommandLineToArgvW` in `Win32CommandLineTests`. There is one `BuildWslArgs` (`ShellSession.BuildWslArgs(string? inner)`); `RunInstance` delegates to it and turns a build failure into a failed run chip rather than a throw. +- **Validation before UI.** `ShellSession.LaunchValidationError` (blank distro / blank SSH host) is checked at the top of `LaunchSessionAsync` before any WebView2 exists. `state.json` and imports are untrusted; a bad entry used to leak a pane. +- **Relaunch paths.** `RecentlyClosedEntry` and the `session_history.snapshot_json` column carry `Kind` + WSL fields, so Ctrl+Shift+T, the "Recently closed" list and relaunch-from-search all restore the right kind. +- **Known gaps:** WSL Claude sessions do not auto-resume on restore (`--resume` id lookup reads the Windows `~/.claude`); a distro name beginning with `-` is not defended against in `wsl.exe` option parsing. + ## Windows Terminal Profile Import (opt-in) When `AppSettings.ImportWindowsTerminalProfiles` is on, the New Session dialog reads the user's Windows Terminal `settings.json` and offers each profile in a "Profile (optional)" combobox. @@ -266,7 +293,7 @@ Closing a session (`Ctrl+W`, sidebar `✕`, or terminal-toolbar close) pushes a Sleep/wake doesn't touch the ring (`SleepSession` bypasses `OnSessionCloseRequested`). `--clean` mode clears the ring at startup (full debug isolation) and never persists changes — `SaveStateAsync` is a no-op in clean mode. -The snapshot model is `Models/RecentlyClosedEntry.cs` — a separate POCO from `ShellSession` so PTY/runtime fields (`IsDormant`, `Status`, `LastActivityAt`) don't leak into the ring buffer. `RunCommands` are deep-copied with fresh Ids on both snapshot creation and session recreation, so edits to either side never alias the other. +The snapshot model is `Models/RecentlyClosedEntry.cs` — a separate POCO from `ShellSession` so PTY/runtime fields (`IsDormant`, `Status`, `LastActivityAt`) don't leak into the ring buffer. `RunCommands` are deep-copied with fresh Ids on both snapshot creation and session recreation, so edits to either side never alias the other. Entries carry `Kind` and the SSH/WSL fields; legacy entries are migrated by `StateService.Normalize` like sessions. FTS5 scrollback retention is **out of scope** for v1 — restored sessions start with an empty xterm buffer. @@ -325,7 +352,7 @@ Each session can have a list of "run commands" — labelled command lines invoke **Data:** `ShellSession.RunCommands: List { Id, Label, CommandLine, IsDefault, Mode, PostRunUrl }`. Exactly one item has `IsDefault=true`; see `RunCommandItem.EnsureSingleDefault`. Persisted to `state.json`. -- **`Mode`** (`RunMode.Process` default / `RunMode.PowerShell`) — `Process` runs through `cmd /c` as before; `PowerShell` wraps the command line in `pwsh.exe -NonInteractive -NoLogo -ExecutionPolicy Bypass -EncodedCommand ` (falls back to `powershell.exe` if `pwsh` isn't on PATH). SSH parents ignore `Mode` — remote runs always go through bash. Use PowerShell when the command relies on pipes (`|`), redirection (`>`), `$env:` variables, or cmdlets. +- **`Mode`** (`RunMode.Process` default / `RunMode.PowerShell`) — `Process` runs through `cmd /c` as before; `PowerShell` wraps the command line in `pwsh.exe -NonInteractive -NoLogo -ExecutionPolicy Bypass -EncodedCommand ` (falls back to `powershell.exe` if `pwsh` isn't on PATH). SSH and WSL parents ignore `Mode` — those runs always go through bash (`ssh … bash -c` / `wsl.exe … bash -lc`). Use PowerShell when the command relies on pipes (`|`), redirection (`>`), `$env:` variables, or cmdlets. - **`PostRunUrl`** (`string?`, default `null`) — when set and the run exits with code 0, `Process.Start` opens the URL via `UseShellExecute=true` (default browser). No health-check polling. The value is gated by `RunInstance.IsLaunchableUrl` first: **only absolute `http`/`https` URLs are launched.** ShellExecute would otherwise run a local exe, a `.ps1`, a UNC path or any registered protocol handler, and this fires automatically with no confirmation — and `ImportExportService` deserializes a whole `AppState` (run commands included) from any JSON file the user points at, so the stored value is not trusted. Rejections and launch failures both append to `crash.log`; neither pops UI, since this runs on the PTY-exit callback thread. Scheme-less input (`localhost:5173`) is rejected rather than guessed at. **Templates:** `RunCommandTemplatesService.SeedFor(folder)` detects project type (top-level scan, first-match: dotnet → cargo → node → python → make) and returns a seed list with fresh Ids. Templates are *copied* onto new sessions at creation time; subsequent edits don't propagate back. SSH sessions skip detection (empty list). diff --git a/README.md b/README.md index 3e9e03a..23a05c0 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,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/docs/superpowers/plans/2026-09-06-wsl-sessions-hardening.md b/docs/superpowers/plans/2026-09-06-wsl-sessions-hardening.md new file mode 100644 index 0000000..f0a4814 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-wsl-sessions-hardening.md @@ -0,0 +1,1465 @@ +# WSL Sessions Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land PR #65 (first-class WSL sessions) on top of current `main`, with every confirmed review finding fixed, the "Edit session" feature taught about WSL, and the legacy `IsRemote` migration moved out of the property setter into the state loader. + +**Architecture:** `ShellSession.Kind` (`Local | Ssh | Wsl`) is the single authority. `IsRemote` becomes a `[JsonIgnore]`d convenience over `Kind`; a JSON-only `LegacyIsRemote` field is read from old `state.json` files and folded into `Kind` by `StateService.Normalize` (the same loader hook that already backfills null collections). Edit/duplicate/reopen flows carry `Kind` plus the three WSL fields through `SessionConfigDraft`, `RecentlyClosedEntry`, and a new `snapshot_json` column in `session_history`. WSL command lines go through one MSVCRT-correct quoting helper. + +**Tech Stack:** .NET 10 / WPF, System.Text.Json, Microsoft.Data.Sqlite, xunit 2.9. + +**Spec:** the review findings recorded in the PR #65 review (this session, 2026-09-06) and the merge decisions in the conversation. Summarised in "Global Constraints" below. + +## Global Constraints + +- Branch: `feat/wsl-sessions-v2` in worktree `C:\Github\umage\CodeShellManager\.claude\worktrees\agent-a9ddd8b8`. Base commit `d4e6fd4` = PR #65 head merged with `origin/main` (658cf00). Never `git stash`; never touch other worktrees. +- Build: `dotnet build src/CodeShellManager/CodeShellManager.csproj -nologo -v q` must print `Build succeeded` with **no new warnings**. +- Tests: `dotnet test tests/CodeShellManager.Tests/ -nologo -v q` must pass with **zero failures** after every task (baseline 400 passing). +- Colours: Catppuccin Mocha hex literals only (see CLAUDE.md "Color / Theme"). +- All `state.json` and import-file content is **untrusted** (CLAUDE.md, `RunInstance.IsLaunchableUrl` precedent). Nothing read from it may throw on the launch or save path, and everything that reaches a command line must be quoted by the shared helper. +- Every task ends with a commit. Commit message trailer (exactly): + ``` + Co-Authored-By: Claude Fable 5.1 + Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu + ``` +- Files are CRLF, UTF-8 with no BOM. Preserve that. +- Run `dotnet test` from the worktree root; it takes ~50 s. +- No Python on this machine. Use PowerShell or the Edit tool for file surgery. + +--- + +### Task 1: Legacy `IsRemote` migration moves into the state loader + +**Why:** PR #65 made `IsRemote`'s setter promote-only (`false` is ignored) so old JSON migrates. That silently broke `SessionConfigEditor.Apply` (`s.IsRemote = false` on an SSH session is a no-op → the session keeps `Kind=Ssh` with a blank host → `FullCommandLine` throws inside `BuildSshArgs` during the next `state.json` serialisation, and every later save fails). Migration belongs in the loader; the property becomes plain API sugar. + +**Files:** +- Modify: `src/CodeShellManager/Models/ShellSession.cs` (lines 45-68, 106-112) +- Modify: `src/CodeShellManager/Models/RecentlyClosedEntry.cs` (lines 24-36, 78) +- Modify: `src/CodeShellManager/Services/StateService.cs` (`Normalize`, ~line 90) +- Modify: `src/CodeShellManager/MainWindow.xaml.cs` (`ReopenClosedSessionAsync`, lines 749-753) +- Modify: `tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs` (rewrite) +- Modify: `tests/CodeShellManager.Tests/ShellSessionTests.cs` (`IsRemote_SetTrue_PromotesKindToSsh`, ~line 95) + +**Interfaces:** +- Produces: `ShellSession.IsRemote { get; set; }` — `[JsonIgnore]`; getter `Kind == Ssh`; setter `true → Kind = Ssh`, `false → Kind = Local` only when `Kind == Ssh` (a WSL session stays WSL). +- Produces: `ShellSession.LegacyIsRemote : bool?` — JSON name `IsRemote`, `[JsonIgnore(Condition = WhenWritingNull)]`, never written after migration. +- Produces: `ShellSession.MigrateLegacyFields()` and `RecentlyClosedEntry.MigrateLegacyFields()` — idempotent, called by `StateService.Normalize`. +- Produces: `[JsonIgnore]` on `IsWsl`, `FullCommandLine`, `FolderShort`, `DefaultDisplayName`, `AccentKey` (ShellSession) and `IsRemote`, `Subtitle` (RecentlyClosedEntry). `FullCommandLine` no longer throws on incomplete sessions. + +- [ ] **Step 1: Write the failing tests** + +Replace the body of `tests/CodeShellManager.Tests/ShellSessionMigrationTests.cs` with: + +```csharp +using System.Text.Json; +using CodeShellManager.Models; +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// State-file migration coverage. Legacy state.json predates the +/// enum and only carried IsRemote. Migration happens in +/// — the loader — not in a property setter, so the +/// in-memory setter can stay a plain two-way switch. +/// +public class ShellSessionMigrationTests +{ + private static AppState LoadState(string json) => + StateService.Normalize(JsonSerializer.Deserialize(json)!); + + [Fact] + public void Normalize_LegacyIsRemoteTrue_PromotesKindToSsh() + { + const string legacy = """ + { "Sessions": [ { "IsRemote": true, "SshUser": "alice", "SshHost": "dev.example.com" } ] } + """; + var s = LoadState(legacy).Sessions[0]; + Assert.Equal(SessionKind.Ssh, s.Kind); + Assert.True(s.IsRemote); + Assert.Null(s.LegacyIsRemote); + } + + [Fact] + public void Normalize_LegacyIsRemoteFalse_KeepsKindLocal() + { + const string legacy = """{ "Sessions": [ { "IsRemote": false, "WorkingFolder": "C:\\proj" } ] }"""; + var s = LoadState(legacy).Sessions[0]; + Assert.Equal(SessionKind.Local, s.Kind); + Assert.False(s.IsRemote); + } + + [Fact] + public void Normalize_KindWslWithStrayLegacyFalse_StaysWsl() + { + const string mixed = """{ "Sessions": [ { "Kind": 2, "IsRemote": false, "WslDistro": "Ubuntu" } ] }"""; + var s = LoadState(mixed).Sessions[0]; + Assert.Equal(SessionKind.Wsl, s.Kind); + } + + [Fact] + public void Normalize_KindWslWithStrayLegacyTrue_KindWins() + { + // Kind is authoritative once present; a stale IsRemote must not clobber it. + const string mixed = """{ "Sessions": [ { "Kind": 2, "IsRemote": true, "WslDistro": "Ubuntu" } ] }"""; + var s = LoadState(mixed).Sessions[0]; + Assert.Equal(SessionKind.Wsl, s.Kind); + } + + [Fact] + public void Normalize_RecentlyClosedLegacyIsRemote_PromotesToSsh() + { + const string legacy = """{ "RecentlyClosed": [ { "IsRemote": true, "SshHost": "h" } ] }"""; + var e = LoadState(legacy).RecentlyClosed[0]; + Assert.Equal(SessionKind.Ssh, e.Kind); + Assert.Null(e.LegacyIsRemote); + } + + [Fact] + public void Serialize_DoesNotWriteLegacyIsRemoteOrComputedProperties() + { + var s = new ShellSession { Kind = SessionKind.Ssh, SshHost = "h" }; + string json = JsonSerializer.Serialize(s); + Assert.DoesNotContain("\"IsRemote\"", json); + Assert.DoesNotContain("FullCommandLine", json); + Assert.DoesNotContain("FolderShort", json); + Assert.DoesNotContain("AccentKey", json); + Assert.Contains("\"Kind\":1", json); + } + + [Fact] + public void Serialize_IncompleteSshSession_DoesNotThrow() + { + // Regression: FullCommandLine used to be serialised and BuildSshArgs threw on a + // blank host, which made every state.json save fail after a bad edit. + var s = new ShellSession { Kind = SessionKind.Ssh, SshHost = "" }; + string json = JsonSerializer.Serialize(s); + Assert.NotNull(json); + Assert.Equal("ssh", s.FullCommandLine); + } + + [Fact] + public void IsRemoteSetter_FalseOnSsh_DemotesToLocal() + { + var s = new ShellSession { Kind = SessionKind.Ssh }; + s.IsRemote = false; + Assert.Equal(SessionKind.Local, s.Kind); + } + + [Fact] + public void IsRemoteSetter_FalseOnWsl_LeavesWsl() + { + var s = new ShellSession { Kind = SessionKind.Wsl }; + s.IsRemote = false; + Assert.Equal(SessionKind.Wsl, s.Kind); + } + + [Fact] + public void Roundtrip_NewFormat_PreservesKind() + { + var original = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "Debian", WslUser = "bob", WslWorkingFolder = "/srv/app", + }; + string json = JsonSerializer.Serialize(original); + var revived = JsonSerializer.Deserialize(json)!; + Assert.Equal(SessionKind.Wsl, revived.Kind); + Assert.Equal("Debian", revived.WslDistro); + Assert.Equal("bob", revived.WslUser); + Assert.Equal("/srv/app", revived.WslWorkingFolder); + } +} +``` + +In `tests/CodeShellManager.Tests/ShellSessionTests.cs` replace the test `IsRemote_SetTrue_PromotesKindToSsh` with: + +```csharp + [Fact] + public void IsRemote_SetTrue_SetsKindSsh() + { + var s = new ShellSession { IsRemote = true }; + Assert.Equal(SessionKind.Ssh, s.Kind); + Assert.True(s.IsRemote); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/CodeShellManager.Tests/ -nologo -v q --filter "FullyQualifiedName~ShellSessionMigrationTests"` +Expected: compile error (`LegacyIsRemote` does not exist) — that counts as red. + +- [ ] **Step 3: Implement in `ShellSession.cs`** + +Add `using System.Text.Json.Serialization;` at the top. Replace the block from the `Kind` doc-comment through `IsWsl` (lines 44-68) with: + +```csharp + /// + /// Authoritative session kind. Everything that branches on session type reads this. + /// Legacy state.json files (pre-Kind) only carried IsRemote; see + /// and . + /// + public SessionKind Kind { get; set; } = SessionKind.Local; + + /// + /// Convenience view of for the SSH case. Setting true makes + /// the session SSH; setting false on an SSH session makes it Local. It is + /// deliberately NOT persisted — is — and it carries no migration + /// logic. A WSL session is unaffected by IsRemote = false. + /// + [JsonIgnore] + public bool IsRemote + { + get => Kind == SessionKind.Ssh; + set + { + if (value) Kind = SessionKind.Ssh; + else if (Kind == SessionKind.Ssh) Kind = SessionKind.Local; + } + } + + /// + /// Read-only compatibility slot for the pre- "IsRemote" JSON + /// key. Populated only when an old file is deserialised; + /// folds it into and nulls it so it is never written back. + /// + [JsonPropertyName("IsRemote")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? LegacyIsRemote { get; set; } + + /// + /// Folds legacy JSON fields into their current representation. Idempotent. Called by + /// StateService.Normalize for every loaded or imported session — the loader is + /// the one place that knows it is looking at possibly-old data. + /// + public void MigrateLegacyFields() + { + if (LegacyIsRemote == true && Kind == SessionKind.Local) Kind = SessionKind.Ssh; + LegacyIsRemote = null; + } + + /// True iff this session runs inside a WSL distro via wsl.exe. + [JsonIgnore] + public bool IsWsl => Kind == SessionKind.Wsl; +``` + +Replace `FullCommandLine` (lines 106-112) with a non-throwing version and mark it ignored: + +```csharp + /// + /// Full command line for display. Never throws: an incomplete session (blank SSH host, + /// blank WSL distro) shows just the executable — this string is used in error dialogs + /// on exactly those paths. + /// + [JsonIgnore] + public string FullCommandLine => Kind switch + { + SessionKind.Ssh => string.IsNullOrWhiteSpace(SshHost) ? "ssh" : $"ssh {BuildSshArgs()}", + SessionKind.Wsl => string.IsNullOrWhiteSpace(WslDistro) ? "wsl.exe" : $"wsl.exe {BuildWslArgs()}", + _ => string.IsNullOrWhiteSpace(Args) ? Command : $"{Command} {Args}", + }; +``` + +Add `[JsonIgnore]` immediately above `FolderShort`, `DefaultDisplayName`, and `AccentKey`. + +- [ ] **Step 4: Implement in `RecentlyClosedEntry.cs`** + +Add `using System.Text.Json.Serialization;`. Replace the `Kind`/`IsRemote` block (lines 24-36) with: + +```csharp + /// + /// Kind of the closed session, so a reopened WSL or SSH session comes back as the same + /// kind instead of Local at a UNC. Legacy entries carried only IsRemote; see + /// . + /// + public SessionKind Kind { get; set; } = SessionKind.Local; + + [JsonIgnore] + public bool IsRemote => Kind == SessionKind.Ssh; + + /// Legacy "IsRemote" JSON slot — see . + [JsonPropertyName("IsRemote")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? LegacyIsRemote { get; set; } + + public void MigrateLegacyFields() + { + if (LegacyIsRemote == true && Kind == SessionKind.Local) Kind = SessionKind.Ssh; + LegacyIsRemote = null; + } +``` + +Delete the line `IsRemote = s.IsRemote,` inside `FromSession`. Add `[JsonIgnore]` above `Subtitle`. Fix `tests/CodeShellManager.Tests/RecentlyClosedEntryTests.cs` object initialisers that use `IsRemote = true` → `Kind = SessionKind.Ssh` (the property is now get-only on the entry; the assertion `Assert.True(e.IsRemote)` still works). + +- [ ] **Step 5: Implement in `StateService.Normalize`** + +```csharp + internal static AppState Normalize(AppState s) + { + s.Sessions ??= []; + s.Groups ??= []; + s.RecentlyClosed ??= []; + s.GroupLayouts ??= new(); + s.Settings ??= new(); + // Legacy-field migration lives here, in the loader, so the models stay free of + // deserialisation-order tricks. Import goes through this too (ImportExportService). + foreach (var session in s.Sessions) session.MigrateLegacyFields(); + foreach (var entry in s.RecentlyClosed) entry.MigrateLegacyFields(); + return s; + } +``` + +- [ ] **Step 6: Remove the setter-based migration in `MainWindow.ReopenClosedSessionAsync`** + +Replace lines 749-753 (the two comments and the `if (entry.Kind == Models.SessionKind.Local) session.IsRemote = entry.IsRemote;` line) with just: + +```csharp + session.Kind = entry.Kind; +``` + +- [ ] **Step 7: Build, run the full suite** + +Run: `dotnet build src/CodeShellManager/CodeShellManager.csproj -nologo -v q` → `Build succeeded`, no new warnings. +Run: `dotnet test tests/CodeShellManager.Tests/ -nologo -v q` → all pass (expect ~408). + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "refactor(sessions): migrate legacy IsRemote in the state loader, not a setter + +The promote-only setter from #65 ignored false, so SessionConfigEditor.Apply +could never flip an SSH session back to Local, and the still-serialised +FullCommandLine then threw on every save. Kind is the only persisted field; +IsRemote is a [JsonIgnore]d two-way convenience; the legacy JSON key lands in +LegacyIsRemote and StateService.Normalize folds it into Kind. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 2: `SessionConfigDraft` / `SessionConfigEditor` learn `Kind` and the WSL fields + +**Files:** +- Modify: `src/CodeShellManager/Models/SessionConfigDraft.cs` +- Modify: `src/CodeShellManager/Services/SessionConfigEditor.cs` +- Modify: `src/CodeShellManager/Views/NewSessionDialog.xaml.cs` (`ToDraft`, minimal compile fix — Task 3 finishes the dialog) +- Modify: `tests/CodeShellManager.Tests/SessionConfigEditorTests.cs` (fixtures at lines 18-26, usages of `d.IsRemote` at 98, 221, 276) + +**Interfaces:** +- Consumes: `ShellSession.Kind`, `WslDiscoveryService.ToUncPath(distro, linuxPath)`. +- Produces: `SessionConfigDraft.Kind : SessionKind`, `WslDistro`, `WslUser`, `WslWorkingFolder : string`. `IsRemote` stays on the draft as `=> Kind == SessionKind.Ssh` (read-only) so existing call sites compile. +- Produces: `SessionConfigChange.WorkingFolderChanged` is also true when a WSL session's distro or Linux folder changed (git info must be re-resolved). +- Produces: `SessionConfigEditor.Apply` writes `s.Kind = d.Kind`; for WSL it derives `s.WorkingFolder = WslDiscoveryService.ToUncPath(d.WslDistro, d.WslWorkingFolder)` — the UNC-mirror invariant every WSL code path relies on. + +- [ ] **Step 1: Write the failing tests** + +Change the fixture in `SessionConfigEditorTests.cs` so `RemoteSession()` uses `Kind = SessionKind.Ssh` instead of `IsRemote = true`, and add a third fixture plus these tests (append inside the class): + +```csharp + private static ShellSession WslSession() => new() + { + Name = "ubuntu proj", + Kind = SessionKind.Wsl, + WslDistro = "Ubuntu", + WslUser = "alice", + WslWorkingFolder = "/home/alice/proj", + WorkingFolder = @"\\wsl$\Ubuntu\home\alice\proj", + Command = "claude", + }; + + [Fact] + public void Apply_SshToLocal_DemotesKind() + { + // Regression: with the promote-only IsRemote setter this silently left Kind=Ssh. + var s = RemoteSession(); + var d = SessionConfigDraft.FromSession(s); + d.Kind = SessionKind.Local; + d.WorkingFolder = @"C:\src"; + + Assert.True(SessionConfigEditor.Diff(s, d).RequiresRelaunch); + SessionConfigEditor.Apply(s, d); + + Assert.Equal(SessionKind.Local, s.Kind); + Assert.False(s.IsRemote); + Assert.Equal(@"C:\src", s.WorkingFolder); + } + + [Fact] + public void Diff_WslIdentical_NoChange() + { + var s = WslSession(); + Assert.False(SessionConfigEditor.Diff(s, SessionConfigDraft.FromSession(s)).AnyChange); + } + + [Fact] + public void Diff_WslDistroChanged_RequiresRelaunchAndFolderChanged() + { + var s = WslSession(); + var d = SessionConfigDraft.FromSession(s); + d.WslDistro = "Debian"; + var c = SessionConfigEditor.Diff(s, d); + Assert.True(c.AnyChange); + Assert.True(c.RequiresRelaunch); + Assert.True(c.WorkingFolderChanged); + } + + [Fact] + public void Diff_WslUserChanged_RequiresRelaunchButFolderUnchanged() + { + var s = WslSession(); + var d = SessionConfigDraft.FromSession(s); + d.WslUser = "root"; + var c = SessionConfigEditor.Diff(s, d); + Assert.True(c.RequiresRelaunch); + Assert.False(c.WorkingFolderChanged); + } + + [Fact] + public void Diff_WslLinuxFolderTrailingSlash_IsNotAChange() + { + var s = WslSession(); + var d = SessionConfigDraft.FromSession(s); + d.WslWorkingFolder = "/home/alice/proj/"; + Assert.False(SessionConfigEditor.Diff(s, d).AnyChange); + } + + [Fact] + public void Apply_WslFolderChanged_ResyncsUncWorkingFolder() + { + var s = WslSession(); + var d = SessionConfigDraft.FromSession(s); + d.WslWorkingFolder = "/srv/other"; + SessionConfigEditor.Apply(s, d); + Assert.Equal("/srv/other", s.WslWorkingFolder); + Assert.Equal(@"\\wsl$\Ubuntu\srv\other", s.WorkingFolder); + } + + [Fact] + public void Apply_LocalToWsl_SetsKindAndUnc() + { + var s = LocalSession(); + var d = SessionConfigDraft.FromSession(s); + d.Kind = SessionKind.Wsl; + d.WslDistro = "Ubuntu"; + d.WslWorkingFolder = "/home/alice"; + Assert.True(SessionConfigEditor.Diff(s, d).RequiresRelaunch); + SessionConfigEditor.Apply(s, d); + Assert.Equal(SessionKind.Wsl, s.Kind); + Assert.Equal(@"\\wsl$\Ubuntu\home\alice", s.WorkingFolder); + } + + [Fact] + public void Diff_StaleWslFieldsOnLocalSession_DoNotCount() + { + var s = LocalSession(); + s.WslDistro = "leftover"; + var d = SessionConfigDraft.FromSession(s); + d.WslDistro = ""; + Assert.False(SessionConfigEditor.Diff(s, d).AnyChange); + } +``` + +Update the three existing usages: `d.IsRemote = true;` → `d.Kind = SessionKind.Ssh;`, and the object initialiser at ~line 221 `IsRemote = false,` → `Kind = SessionKind.Local,`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test tests/CodeShellManager.Tests/ -nologo -v q --filter "FullyQualifiedName~SessionConfigEditorTests"` +Expected: compile error on `d.Kind` / `WslDistro`. + +- [ ] **Step 3: Implement `SessionConfigDraft`** + +Replace the `// Remote` block and `FromSession` in `SessionConfigDraft.cs`: + +```csharp + // Kind + kind-specific fields. Kind is authoritative; IsRemote is a read-only view. + public SessionKind Kind { get; set; } = SessionKind.Local; + public bool IsRemote => Kind == SessionKind.Ssh; + public string SshUser { get; set; } = ""; + public string SshHost { get; set; } = ""; + public int SshPort { get; set; } = 22; + public string SshRemoteFolder { get; set; } = ""; + public string WslDistro { get; set; } = ""; + public string WslUser { get; set; } = ""; + public string WslWorkingFolder { get; set; } = ""; +``` + +and in `FromSession` replace `IsRemote = s.IsRemote,` with: + +```csharp + Kind = s.Kind, + WslDistro = s.WslDistro, + WslUser = s.WslUser, + WslWorkingFolder = s.WslWorkingFolder, +``` + +- [ ] **Step 4: Implement `SessionConfigEditor.Diff` / `Apply`** + +In `Diff`, replace the `modeChanged`, `folderChanged`, `sshChanged` block with: + +```csharp + bool modeChanged = d.Kind != s.Kind; + + // Kind-specific fields only count while the kind is unchanged — a kind flip already + // forces a relaunch, and leftovers from a previous kind must not read as edits. + bool sameKind = !modeChanged; + bool folderChanged = sameKind && s.Kind == SessionKind.Local + && !PathsEqual(d.WorkingFolder, s.WorkingFolder); + + bool sshChanged = sameKind && s.Kind == SessionKind.Ssh + && (!Eq(d.SshUser, s.SshUser) + || !Eq(d.SshHost, s.SshHost) + || d.SshPort != s.SshPort + || !Eq(d.SshRemoteFolder, s.SshRemoteFolder)); + + bool wslFolderChanged = sameKind && s.Kind == SessionKind.Wsl + && (!Eq(d.WslDistro, s.WslDistro) + || !LinuxPathsEqual(d.WslWorkingFolder, s.WslWorkingFolder)); + bool wslChanged = wslFolderChanged + || (sameKind && s.Kind == SessionKind.Wsl && !Eq(d.WslUser, s.WslUser)); +``` + +then include them: `anyChange = modeChanged || folderChanged || sshChanged || wslChanged || launchChanged || appearanceChanged || !Eq(d.Name, s.Name);`, `requiresRelaunch = modeChanged || folderChanged || sshChanged || wslChanged || launchChanged || transparencyChanged || overridesCleared;` and return `WorkingFolderChanged: folderChanged || wslFolderChanged`. + +Add the helper next to `PathsEqual`: + +```csharp + /// Linux path compare: exact, trailing-slash tolerant, case-sensitive (ext4 is). + internal static bool LinuxPathsEqual(string a, string b) => + string.Equals((a ?? "").Trim().TrimEnd('/'), (b ?? "").Trim().TrimEnd('/'), StringComparison.Ordinal); +``` + +In `Apply`, replace `s.IsRemote = d.IsRemote; s.WorkingFolder = d.WorkingFolder;` with: + +```csharp + s.Kind = d.Kind; + s.WslDistro = d.WslDistro; + s.WslUser = d.WslUser; + s.WslWorkingFolder = d.WslWorkingFolder.Trim(); + // WSL sessions keep WorkingFolder as the \\wsl$ UNC mirror of the Linux path so + // Explorer, git polling and the sidebar need no special-casing (see CLAUDE.md + // "WSL Sessions"). Derive it here so the two can never drift apart. + s.WorkingFolder = d.Kind == SessionKind.Wsl + ? WslDiscoveryService.ToUncPath(d.WslDistro, s.WslWorkingFolder) + : d.WorkingFolder; +``` + +Update the `` doc to say "local folder or WSL distro/Linux folder moved". + +- [ ] **Step 5: Build + full tests** + +Run: `dotnet build src/CodeShellManager/CodeShellManager.csproj -nologo -v q` — expect a compile error in `NewSessionDialog.ToDraft` (`IsRemote = IsRemote` on a get-only property). Fix minimally now so the build passes: in `NewSessionDialog.ToDraft()` replace `IsRemote = IsRemote,` with + +```csharp + Kind = IsWsl ? SessionKind.Wsl : IsRemote ? SessionKind.Ssh : SessionKind.Local, + WslDistro = WslDistro, + WslUser = WslUser, + WslWorkingFolder = WslWorkingFolder, +``` + +(`IsWsl`, `IsRemote`, `WslDistro`… here are the dialog's own output properties, set in `Start_Click`). Add `using CodeShellManager.Models;` if missing. +Run: `dotnet test tests/CodeShellManager.Tests/ -nologo -v q` → all pass. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat(edit-session): carry Kind and WSL fields through SessionConfigDraft + +Diff treats distro/user/Linux-folder like the SSH target fields; Apply sets +Kind directly and re-derives the UNC WorkingFolder so it can't drift from +WslWorkingFolder. Adds the SSH->Local regression test. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 3: "Edit session…" works for WSL sessions (dialog + MainWindow) + +**Files:** +- Modify: `src/CodeShellManager/Views/NewSessionDialog.xaml.cs` (`_preselectWslDistro` ~line 88, ctor `Loaded` ~line 174-180, `PopulateWslDistrosAsync` ~line 187, `ForEdit` ~line 216, `ApplyEditMode` ~line 237, `SetUpEditModeProfileCombo` ~line 286, `SessionType_Changed` ~line 460) +- Modify: `src/CodeShellManager/MainWindow.xaml.cs` (`EditSessionAsync` lines 4574-4585; boot label line ~1330) + +**Interfaces:** +- Consumes: `SessionConfigDraft.Kind/WslDistro/WslUser/WslWorkingFolder` (Task 2). +- Produces: `NewSessionDialog.ForEdit(session, …)` pre-fills WSL mode (distro pre-selected once the async list loads, user + Linux folder boxes filled). Profile/appearance panel is shown for Local **and** WSL sessions (xterm appearance is independent of what runs inside), hidden only for SSH — same rule in create and edit mode. + +- [ ] **Step 1: Make the preselect field assignable** + +Change `private readonly string _preselectWslDistro = "";` to `private string _preselectWslDistro = "";`. + +- [ ] **Step 2: `ForEdit` passes the local folder only for Local sessions** + +```csharp + defaultFolder: session.Kind == SessionKind.Local ? session.WorkingFolder : "", +``` + +- [ ] **Step 3: `ApplyEditMode` handles all three kinds** + +Replace the `if (s.IsRemote) { … }` block with: + +```csharp + switch (s.Kind) + { + case SessionKind.Ssh: + // Checking the radio runs SessionType_Changed, which swaps the panels. It no + // longer blanks NameBox — that handler returns early in edit mode — so the + // assignment below is the only thing setting the name, not a repair. + RemoteRadio.IsChecked = true; + SshHostBox.Text = string.IsNullOrWhiteSpace(s.SshUser) + ? s.SshHost + : $"{s.SshUser}@{s.SshHost}"; + SshPortBox.Text = s.SshPort.ToString(); + SshRemoteFolderBox.Text = s.SshRemoteFolder; + break; + case SessionKind.Wsl: + WslRadio.IsChecked = true; + WslUserBox.Text = s.WslUser; + WslWorkingFolderBox.Text = s.WslWorkingFolder; + // The distro combo is filled asynchronously on Loaded; PopulateWslDistrosAsync + // selects this name once the list arrives. + _preselectWslDistro = s.WslDistro; + break; + } +``` + +- [ ] **Step 4: `Loaded` populates distros in edit mode too** + +Replace the `Loaded += …` lambda body with: + +```csharp + Loaded += async (_, _) => + { + // The distro list is needed in every mode the WSL radio can be reached from, + // including edit mode — otherwise editing a WSL session shows an empty combo. + await PopulateWslDistrosAsync(); + // Sibling-worktree fan-out only makes sense when creating sessions. + if (IsEditMode) return; + if (IsLocalMode && !string.IsNullOrWhiteSpace(FolderBox.Text)) + await ProbeSiblingWorktreesAsync(FolderBox.Text.Trim()); + }; +``` + +In `PopulateWslDistrosAsync`, after the list is built (including the `distros.Count == 0` case), if `_preselectWslDistro` is non-empty and no item matched it, add `new ComboBoxItem { Content = $"{_preselectWslDistro} (not installed)", Tag = _preselectWslDistro }` and select it, so editing a session whose distro was removed does not silently wipe the distro on Save. Keep the "No WSL distros found" hint when the list was empty. + +- [ ] **Step 5: Profile panel rule — hide only for SSH** + +In `SessionType_Changed`: `ProfilePanel.Visibility = IsRemoteMode ? Visibility.Collapsed : Visibility.Visible;` and update the comment to "Appearance overrides apply to any xterm-hosted session, WSL included; SSH is excluded because the remote profile is out of our hands." In `SetUpEditModeProfileCombo`: `ProfilePanel.Visibility = s.Kind == SessionKind.Ssh ? Visibility.Collapsed : Visibility.Visible;`. + +- [ ] **Step 6: MainWindow edit flow compares Kind; boot label per kind** + +In `EditSessionAsync` replace `bool wasRemote = session.IsRemote;` with `var wasKind = session.Kind;` and the condition with `if (change.WorkingFolderChanged || session.Kind != wasKind)`. + +At line ~1330 replace the `bootLabel` expression with: + +```csharp + string bootLabel = session.Kind switch + { + Models.SessionKind.Ssh => $"Connecting to {session.SshHost}…", + Models.SessionKind.Wsl => $"Starting {session.WslDistro}…", + _ => $"Starting {(string.IsNullOrWhiteSpace(session.Command) ? "session" : session.Command)}…", + }; +``` + +- [ ] **Step 7: Build + full tests** + +Run build and tests as in Global Constraints. Expected: `Build succeeded`, all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "feat(edit-session): edit WSL sessions in the shared New Session form + +ForEdit pre-selects the distro and fills user/Linux folder, the distro list is +populated in edit mode, and the appearance panel is available for WSL sessions +(only SSH hides it). Boot label names the distro. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 4: One MSVCRT-correct quoting helper; one `BuildWslArgs` + +**Why:** `QuoteForCmd` and both `BuildWslArgs` bodies escape `"` as `\"` but never double the backslashes that precede a quote (or the closing quote). Verified against `CommandLineToArgvW`: `sed -i 's/\"//g' f.txt` splits into stray argv; a command ending in `\` swallows the closing quote. + +**Files:** +- Modify: `src/CodeShellManager/Models/ShellSession.cs` (`BuildWslArgs`, `QuoteForCmd`, lines ~140-175) +- Modify: `src/CodeShellManager/Services/RunInstance.cs` (`BuildWslArgs`, lines 262-280; `Start`, lines 75-128) +- Create: `tests/CodeShellManager.Tests/Win32CommandLineTests.cs` +- Modify: `tests/CodeShellManager.Tests/ShellSessionTests.cs`, `tests/CodeShellManager.Tests/RunInstanceTests.cs` (only if an existing expectation encoded the buggy shape — the plain-quote cases keep their current expected strings) + +**Interfaces:** +- Produces: `ShellSession.QuoteForCmd(string value, bool force = false)` — MSVCRT rules: 2n backslashes before `"` → n literal backslashes + escaped quote; trailing backslashes doubled before the closing quote; `force` always wraps in quotes. +- Produces: `ShellSession.BuildWslArgs(string? inner = null)` — `inner` replaces the `Command + Args` payload (used for run commands). Throws `InvalidOperationException` on blank `WslDistro` (both callers now agree). +- Produces: `RunInstance.BuildWslArgs(parent, commandLine)` delegates to `parent.BuildWslArgs(commandLine)`. + +- [ ] **Step 1: Write the failing round-trip tests** + +Create `tests/CodeShellManager.Tests/Win32CommandLineTests.cs`: + +```csharp +using System; +using System.Runtime.InteropServices; +using CodeShellManager.Models; +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// Round-trips our quoting through the real Win32 tokenizer. wsl.exe is started via +/// CreateProcess with no outer shell, so what CommandLineToArgvW produces is exactly what +/// wsl.exe (and then bash -lc) receives. Hand-written expectations were how the original +/// backslash bug slipped through. +/// +public class Win32CommandLineTests +{ + [DllImport("shell32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr CommandLineToArgvW(string lpCmdLine, out int pNumArgs); + + [DllImport("kernel32.dll")] + private static extern IntPtr LocalFree(IntPtr hMem); + + internal static string[] Split(string commandLine) + { + // Prefix a dummy program name: CommandLineToArgvW parses argv[0] with different rules. + IntPtr argv = CommandLineToArgvW("x.exe " + commandLine, out int argc); + if (argv == IntPtr.Zero) throw new InvalidOperationException("CommandLineToArgvW failed"); + try + { + var result = new string[argc - 1]; + for (int i = 1; i < argc; i++) + result[i - 1] = Marshal.PtrToStringUni(Marshal.ReadIntPtr(argv, i * IntPtr.Size))!; + return result; + } + finally { LocalFree(argv); } + } + + [Theory] + [InlineData("plain")] + [InlineData("has space")] + [InlineData("say \"hi\"")] + [InlineData("trailing\\")] + [InlineData("back\\\"slash-quote")] + [InlineData("two\\\\\"bs")] + [InlineData("sed -i 's/\\\"//g' f.txt")] + [InlineData("grep -r \"\\\"foo\\\"\" .")] + [InlineData("cp -r /src /dst\\")] + [InlineData("")] + public void QuoteForCmd_RoundTripsThroughCommandLineToArgvW(string value) + { + string[] argv = Split(ShellSession.QuoteForCmd(value, force: true)); + Assert.Single(argv); + Assert.Equal(value, argv[0]); + } + + [Theory] + [InlineData("cargo test")] + [InlineData("echo \"hi\"")] + [InlineData("sed -i 's/\\\"//g' f.txt")] + [InlineData("cp -r /src /dst\\")] + [InlineData("printf '%s\\n' \"$HOME\"")] + public void RunInstanceBuildWslArgs_BashPayloadArrivesIntact(string commandLine) + { + var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu", WslWorkingFolder = "/home/a b" }; + string[] argv = Split(RunInstance.BuildWslArgs(p, commandLine)); + Assert.Equal(new[] { "-d", "Ubuntu", "--cd", "/home/a b", "--", "bash", "-lc", commandLine }, argv); + } + + [Fact] + public void ShellSessionBuildWslArgs_DistroWithSpaceAndUser_Tokenizes() + { + var s = new ShellSession + { + Kind = SessionKind.Wsl, WslDistro = "My Distro", WslUser = "alice", + WslWorkingFolder = "/home/alice", Command = "claude", Args = "--prompt \"fix the \\\"foo\\\" bug\"", + }; + string[] argv = Split(s.BuildWslArgs()); + Assert.Equal(new[] { "-d", "My Distro", "-u", "alice", "--cd", "/home/alice", "--", "bash", "-lc", + "claude --prompt \"fix the \\\"foo\\\" bug\"" }, argv); + } + + [Fact] + public void RunInstanceBuildWslArgs_BlankDistro_Throws() + { + var p = new ShellSession { Kind = SessionKind.Wsl, WslDistro = "" }; + Assert.Throws(() => RunInstance.BuildWslArgs(p, "ls")); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/CodeShellManager.Tests/ -nologo -v q --filter "FullyQualifiedName~Win32CommandLineTests"` +Expected: compile error (`force` parameter missing); after adding only the parameter, the `trailing\` / `sed` cases fail. + +- [ ] **Step 3: Implement `QuoteForCmd` and unify `BuildWslArgs` in `ShellSession.cs`** + +```csharp + /// + /// Win32 (MSVCRT / CommandLineToArgvW) argument quoting. Space-free, quote-free values + /// are returned unchanged unless is set. Inside quotes, a + /// run of n backslashes followed by " becomes 2n+1 backslashes + quote, and a + /// trailing run of n backslashes becomes 2n so it cannot eat the closing quote. + /// Every value that reaches wsl.exe goes through here — no ad-hoc Replace. + /// + internal static string QuoteForCmd(string value, bool force = false) + { + value ??= ""; + if (!force && value.Length > 0 && value.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) + return value; + + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + int backslashes = 0; + foreach (char c in value) + { + if (c == '\\') { backslashes++; continue; } + if (c == '"') + { + sb.Append('\\', backslashes * 2 + 1).Append('"'); + backslashes = 0; + continue; + } + sb.Append('\\', backslashes).Append(c); + backslashes = 0; + } + sb.Append('\\', backslashes * 2); + sb.Append('"'); + return sb.ToString(); + } + + /// + /// Builds the argument string passed to wsl.exe: + /// -d <distro> [-u <user>] [--cd <linux-folder>] -- bash -lc "<payload>". + /// The payload is + , or + /// when given (run commands). It is wrapped in bash -lc so PATH-resolved tools + /// (nvm node, pyenv, …) behave as in a login shell; bash then interprets the payload as + /// a shell command line, which is the intent. Throws when is + /// blank — callers validate first (LaunchValidationError, Task 5). + /// + internal string BuildWslArgs(string? inner = null) + { + if (string.IsNullOrWhiteSpace(WslDistro)) + throw new InvalidOperationException("WslDistro must be set for WSL sessions."); + var sb = new StringBuilder(); + sb.Append("-d ").Append(QuoteForCmd(WslDistro)); + if (!string.IsNullOrWhiteSpace(WslUser)) + sb.Append(" -u ").Append(QuoteForCmd(WslUser)); + if (!string.IsNullOrWhiteSpace(WslWorkingFolder)) + sb.Append(" --cd ").Append(QuoteForCmd(WslWorkingFolder)); + if (inner is null) + { + var shell = string.IsNullOrWhiteSpace(Command) ? "bash" : Command; + inner = string.IsNullOrWhiteSpace(Args) ? shell : $"{shell} {Args}"; + } + sb.Append(" -- bash -lc ").Append(QuoteForCmd(inner, force: true)); + return sb.ToString(); + } +``` + +- [ ] **Step 4: `RunInstance.BuildWslArgs` delegates; `Start` fails gracefully** + +Replace the whole `RunInstance.BuildWslArgs` method with: + +```csharp + /// + /// wsl.exe args for a run inside the parent's distro. One implementation with the + /// session launcher — see — so the two can't + /// disagree about quoting or about a blank distro. + /// + internal static string BuildWslArgs(ShellSession parent, string commandLine) + => parent.BuildWslArgs(commandLine); +``` + +In `RunInstance.Start`, wrap the `switch (parent.Kind) { … }` and `_pty.Start(…)` in a `try`. In the `catch (Exception ex)`: append `$"Cannot start: {ex.Message}\r\n"` to the ANSI-stripped buffer (reuse whatever method already appends to `_ansiStripped` under `_bufLock` and raises `OutputChanged` — search the file; add a small private `AppendText(string)` if there is only inline code), set `ExitCode = -1`, `EndedAt = DateTime.Now`, `State` to the failed member of `RunState` (check the enum), raise `StateChanged`, detach the two PTY handlers, `_pty.Dispose()`, `_pty = null`, and return. A run that cannot even build its command line must show as a failed chip, not throw out of the toolbar click. + +- [ ] **Step 5: Reconcile existing tests** + +Run the full suite. If any pre-existing `BuildWslArgs`/`QuoteForCmd` expectations differ **only** in the buggy escaping shape, update them to the round-tripped output. Expectations that assert plain `"..."` wrapping (`-d Debian -- bash -lc "ls"`) still hold. + +- [ ] **Step 6: Build + full tests → commit** + +```bash +git add -A +git commit -m "fix(wsl): MSVCRT-correct quoting, one BuildWslArgs for sessions and runs + +QuoteForCmd now doubles backslashes before quotes and at the end of a quoted +value; verified by round-tripping through CommandLineToArgvW. RunInstance +delegates to ShellSession.BuildWslArgs and reports a start failure in the run +output instead of throwing from the toolbar click. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 5: Validate a session before creating any UI for it + +**Why:** `session.BuildWslArgs()` (and `BuildSshArgs()`) run at MainWindow.xaml.cs:1370-1379, *after* the WebView2 and terminal wrapper exist and *outside* the `try` at 1419. A `state.json`/import entry with `Kind: 2, WslDistro: ""` leaks a pane and a stuck launching placeholder on every restore. + +**Files:** +- Modify: `src/CodeShellManager/Models/ShellSession.cs` (add `LaunchValidationError`) +- Modify: `src/CodeShellManager/MainWindow.xaml.cs` (`LaunchSessionAsync` top, ~line 1261) +- Modify: `tests/CodeShellManager.Tests/ShellSessionTests.cs` + +**Interfaces:** +- Produces: `ShellSession.LaunchValidationError : string?` — `[JsonIgnore]`; null when launchable; otherwise a user-facing sentence. + +- [ ] **Step 1: Tests** + +Append to `ShellSessionTests`: + +```csharp + [Fact] + public void LaunchValidationError_Local_IsNull() => + Assert.Null(new ShellSession { Kind = SessionKind.Local, Command = "claude" }.LaunchValidationError); + + [Fact] + public void LaunchValidationError_SshBlankHost_Reports() => + Assert.Contains("host", new ShellSession { Kind = SessionKind.Ssh }.LaunchValidationError!, StringComparison.OrdinalIgnoreCase); + + [Fact] + public void LaunchValidationError_WslBlankDistro_Reports() => + Assert.Contains("distro", new ShellSession { Kind = SessionKind.Wsl }.LaunchValidationError!, StringComparison.OrdinalIgnoreCase); + + [Fact] + public void LaunchValidationError_WslWithDistro_IsNull() => + Assert.Null(new ShellSession { Kind = SessionKind.Wsl, WslDistro = "Ubuntu" }.LaunchValidationError); +``` + +- [ ] **Step 2: Run → fail (compile error).** + +- [ ] **Step 3: Implement** + +In `ShellSession.cs`, after `FullCommandLine`: + +```csharp + /// + /// Null when the session has everything it needs to launch; otherwise a sentence for + /// the user. Checked at the top of MainWindow.LaunchSessionAsync BEFORE any + /// WebView2 or PTY is created, because the arg builders throw on these and a throw at + /// that point leaks the pane. state.json and imports are untrusted input. + /// + [JsonIgnore] + public string? LaunchValidationError => Kind switch + { + SessionKind.Ssh when string.IsNullOrWhiteSpace(SshHost) => "This SSH session has no host. Edit the session and set one.", + SessionKind.Wsl when string.IsNullOrWhiteSpace(WslDistro) => "This WSL session has no distro. Edit the session and pick one.", + _ => null, + }; +``` + +In `LaunchSessionAsync`, right after the opening `Log($"LaunchSession START…")` line, add: + +```csharp + if (session.LaunchValidationError is { } validationError) + { + Log($"LaunchSession REFUSED: {validationError}"); + MessageBox.Show(this, $"Cannot start '{session.Name}'.\n\n{validationError}", + "Launch Error", MessageBoxButton.OK, MessageBoxImage.Warning); + if (removeOnFailure) _sessionManager.RemoveSession(session.Id); + else { session.IsDormant = true; AddDormantSidebarItem(session); } + if (_launchingSidebarItems.Remove(session.Id)) RebuildSidebarOrder(); + return; + } +``` + +Confirm `AddDormantSidebarItem(ShellSession)` exists with that signature (CLAUDE.md "Sleep / Wake"). With `removeOnFailure: true` (the default, used by restore) a broken restored session is dropped with the message above — the same outcome as a failed PTY start today. + +- [ ] **Step 4: Build + full tests → commit** + +```bash +git add -A +git commit -m "fix(launch): refuse unlaunchable sessions before creating a pane + +A WSL session with a blank distro (or SSH with a blank host) used to throw from +BuildWslArgs after the WebView2 and wrapper existed and outside the failure +handler, leaking both. Validate first via ShellSession.LaunchValidationError. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 6: Git polling off the UI thread, with a WSL-aware cadence + +**Why:** `GitService.GetGitInfoAsync`/`GetRepoRootAsync` start with a synchronous `Directory.Exists` on the caller's thread, and `SessionViewModel.RefreshGitInfoAsync` runs on the dispatcher (constructor + `PeriodicTimer` continuation). On a `\\wsl$` path with a stopped distro that call boots the VM and freezes the UI for seconds, at startup and every 10 s. Also, a WSL session in a non-repo folder spawns a third `wsl.exe` every tick because a null `RepoRoot` is re-probed forever. + +**Files:** +- Modify: `src/CodeShellManager/ViewModels/SessionViewModel.cs` (lines 79-97, 111-120, `ReloadGitInfoAsync`) +- Create: `tests/CodeShellManager.Tests/SessionViewModelGitPollingTests.cs` + +**Interfaces:** +- Produces: `SessionViewModel.GitPollIntervalFor(SessionKind) : TimeSpan` — `internal static`; 10 s Local, 30 s Wsl. +- Produces: negative RepoRoot cache for WSL only (`_repoRootProbedNegative`), reset by `ReloadGitInfoAsync`. + +- [ ] **Step 1: Tests** + +```csharp +using System; +using CodeShellManager.Models; +using CodeShellManager.ViewModels; +using Xunit; + +namespace CodeShellManager.Tests; + +public class SessionViewModelGitPollingTests +{ + [Fact] + public void GitPollInterval_LocalIsTenSeconds() => + Assert.Equal(TimeSpan.FromSeconds(10), SessionViewModel.GitPollIntervalFor(SessionKind.Local)); + + [Fact] + public void GitPollInterval_WslIsSlower() + { + // Each WSL probe is a wsl.exe spawn (LxssManager hop) and keeps the VM awake; + // three times the local cadence is the documented trade. + Assert.Equal(TimeSpan.FromSeconds(30), SessionViewModel.GitPollIntervalFor(SessionKind.Wsl)); + } +} +``` + +- [ ] **Step 2: Run → compile failure.** + +- [ ] **Step 3: Implement** + +In `SessionViewModel.cs`: + +```csharp + /// + /// Git poll cadence per kind. WSL probes spawn wsl.exe (much heavier than a local git + /// spawn) and defeat WSL2's idle-VM shutdown, so they run a third as often. + /// + internal static TimeSpan GitPollIntervalFor(SessionKind kind) => + kind == SessionKind.Wsl ? TimeSpan.FromSeconds(30) : TimeSpan.FromSeconds(10); + + // WSL only: a "not a repo" answer costs a wsl.exe spawn per tick, so remember it. + // Local folders keep re-probing (a `git init` should be picked up within a tick). + private bool _repoRootProbedNegative; + + public async Task RefreshGitInfoAsync() + { + // SSH sessions have no local working folder to inspect. WSL sessions store + // their WorkingFolder as a `\\wsl$\\...` UNC; GitService detects that + // and dispatches to `wsl.exe -- git -C ` internally (Git for + // Windows itself trips on those UNCs — dubious-ownership / .git symlinks). + if (Session.Kind == SessionKind.Ssh || _gitOverriddenByOsc) return; + + // Off the dispatcher: GitService begins with a synchronous Directory.Exists, and on + // a \\wsl$ share that boots a stopped distro (seconds). Continuations return to the + // captured UI context, so the property sets below stay on the UI thread. + string folder = Session.WorkingFolder; + var (branch, isDirty) = await Task.Run(() => GitService.GetGitInfoAsync(folder)); + GitBranch = branch; + GitIsDirty = isDirty; + GitInfoLoaded = true; + + // RepoRoot is stable for the life of the session — resolve it once. Don't gate on + // a non-empty branch: detached HEADs report no branch but are still valid repos + // that should participate in sibling detection, shared accent color, and clusters. + if (RepoRoot == null && !_repoRootProbedNegative) + { + RepoRoot = await Task.Run(() => GitService.GetRepoRootAsync(folder)); + if (RepoRoot == null && Session.Kind == SessionKind.Wsl) _repoRootProbedNegative = true; + } + } +``` + +In `PollGitInfoAsync`: `using var timer = new PeriodicTimer(GitPollIntervalFor(Session.Kind));`. + +In `ReloadGitInfoAsync` (it already clears `RepoRoot` and `_gitOverriddenByOsc`), also set `_repoRootProbedNegative = false;`. + +- [ ] **Step 4: Build + full tests → commit** + +```bash +git add -A +git commit -m "perf(git): probe off the UI thread; slower cadence and negative cache for WSL + +Directory.Exists on a wsl share boots a stopped distro and was running on the +dispatcher at startup and every tick. WSL sessions now poll every 30s and +remember a not-a-repo answer instead of spawning wsl.exe for it forever. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 7: One UNC parser; distro-name boundary in the arg translator + +**Why:** `TranslateUncArgsToLinux`'s regex has no boundary after the distro name, so with distro `Ubuntu` the UNC `\\wsl$\Ubuntu-22.04\home\alice\x` is rewritten to `/-22.04\home\alice\x` — and `git worktree add` inside Ubuntu then creates that directory. Separately, `NewSessionDialog.ParseWslUncPath` and `GitService.TryParseWslUnc` are two parsers of the same shape with different root conventions. + +**Files:** +- Modify: `src/CodeShellManager/Services/WslDiscoveryService.cs` (add `TryParseUncPath`) +- Modify: `src/CodeShellManager/Services/GitService.cs` (`TryParseWslUnc` delegates; `TranslateUncArgsToLinux` boundary) +- Modify: `src/CodeShellManager/Views/NewSessionDialog.xaml.cs` (`ParseWslUncPath` delegates) +- Modify: `tests/CodeShellManager.Tests/GitServiceWslRoutingTests.cs`, `tests/CodeShellManager.Tests/WslDiscoveryServiceTests.cs` + +**Interfaces:** +- Produces: `WslDiscoveryService.TryParseUncPath(string path) : (string? distro, string linuxPath)` — `linuxPath` is `"/"` for the distro root, `""` when not a WSL UNC. Accepts `\\wsl$\` and `\\wsl.localhost\`, either slash direction, case-insensitive prefix. +- `GitService.TryParseWslUnc(path)` becomes `=> WslDiscoveryService.TryParseUncPath(path)`. +- `NewSessionDialog.ParseWslUncPath(unc)` becomes a thin wrapper that maps `null → ""` and `"/" → ""` (dialog convention: blank Linux folder = home). `NewSessionDialogTests` must keep passing unchanged. + +- [ ] **Step 1: Tests** + +Add to `GitServiceWslRoutingTests`: + +```csharp + [Fact] + public void TranslateUncArgsToLinux_PrefixCollidingDistro_LeftAlone() + { + // `Ubuntu` must not match `Ubuntu-22.04` — the default `wsl --install` naming. + string args = "worktree add \"\\\\wsl$\\Ubuntu-22.04\\home\\alice\\x\" main"; + Assert.Equal(args, GitService.TranslateUncArgsToLinux(args, "Ubuntu")); + string bare = "worktree add \\\\wsl$\\Ubuntu-22.04\\home\\alice\\x main"; + Assert.Equal(bare, GitService.TranslateUncArgsToLinux(bare, "Ubuntu")); + } + + [Fact] + public void TranslateUncArgsToLinux_DistroRootUnquoted_BecomesSlash() + { + Assert.Equal("-C / status", GitService.TranslateUncArgsToLinux("-C \\\\wsl$\\Ubuntu status", "Ubuntu")); + } +``` + +Add to `WslDiscoveryServiceTests`: + +```csharp + [Theory] + [InlineData(@"\\wsl$\Ubuntu\home\alice", "Ubuntu", "/home/alice")] + [InlineData(@"\\wsl.localhost\Debian\srv\app", "Debian", "/srv/app")] + [InlineData(@"\\WSL$\Ubuntu\", "Ubuntu", "/")] + [InlineData(@"//wsl$/Ubuntu/home/alice", "Ubuntu", "/home/alice")] + [InlineData(@"\\wsl$\Ubuntu", "Ubuntu", "/")] + [InlineData(@"\\wsl$\", null, "")] + [InlineData(@"C:\proj", null, "")] + [InlineData("", null, "")] + public void TryParseUncPath_KnownShapes(string path, string? distro, string linux) + { + var (d, l) = WslDiscoveryService.TryParseUncPath(path); + Assert.Equal(distro, d); + Assert.Equal(linux, l); + } +``` + +- [ ] **Step 2: Run → the two new GitService tests fail; the discovery tests fail to compile.** + +- [ ] **Step 3: Implement** + +In `WslDiscoveryService.cs` add: + +```csharp + /// + /// Splits a WSL UNC (\\wsl$\Ubuntu\home\alice or \\wsl.localhost\…, either + /// slash direction) into (distro, linuxPath). linuxPath is "/" for the distro root. + /// Returns (null, "") for anything that isn't a WSL UNC. The single parser for the + /// whole app — GitService and NewSessionDialog both delegate here. + /// + public static (string? distro, string linuxPath) TryParseUncPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) return (null, ""); + string normalized = path.Replace('/', '\\').TrimEnd('\\'); + foreach (var prefix in new[] { @"\\wsl$\", @"\\wsl.localhost\" }) + { + if (!normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; + string rest = normalized[prefix.Length..]; + if (string.IsNullOrEmpty(rest)) return (null, ""); + int slash = rest.IndexOf('\\'); + string distro = slash < 0 ? rest : rest[..slash]; + string linuxRest = slash < 0 ? "" : rest[(slash + 1)..]; + return (distro, string.IsNullOrEmpty(linuxRest) ? "/" : "/" + linuxRest.Replace('\\', '/')); + } + return (null, ""); + } +``` + +In `GitService.cs` replace the body of `TryParseWslUnc` with `=> WslDiscoveryService.TryParseUncPath(path);` (keep the `internal static` signature). In `TranslateUncArgsToLinux` change the `body` line to: + +```csharp + // Lookahead: the distro name must be followed by a separator, a quote, whitespace or + // end-of-string — otherwise `Ubuntu` also matches `Ubuntu-22.04`. + string body = $@"\\\\wsl(?:\$|\.localhost)\\{esc}(?=[\\""\s]|$)"; +``` + +In `NewSessionDialog.xaml.cs` replace the body of `ParseWslUncPath` with: + +```csharp + var (distro, linux) = WslDiscoveryService.TryParseUncPath(unc); + // Dialog convention: blank Linux folder means "the user's home", so the distro root + // ("/") from the shared parser is reported as "" here. + return distro is null ? ("", "") : (distro, linux == "/" ? "" : linux); +``` + +- [ ] **Step 4: Build + full tests → commit** + +```bash +git add -A +git commit -m "fix(wsl): one UNC parser, and a distro-name boundary in the arg translator + +Ubuntu no longer matches Ubuntu-22.04 when translating wsl UNC args for git; +GitService and NewSessionDialog share WslDiscoveryService.TryParseUncPath. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 8: Relaunch-from-search preserves the session kind + +**Why:** `TryRelaunchFromHistoryAsync` recreates a session from `session_history`, which holds seven kind-agnostic columns. A closed WSL (or SSH) session relaunched from a search hit comes back Local at the UNC and runs the Windows `claude`. `RecentlyClosedEntry` already carries everything needed — store it alongside. + +**Files:** +- Modify: `src/CodeShellManager/Services/SearchService.cs` (schema ~line 84-95, `SessionHistoryEntry` record line 22, `RecordSessionHistoryAsync`, both `GetSessionHistory*` readers) +- Modify: `src/CodeShellManager/MainWindow.xaml.cs` (`pty.Exited` handler ~line 1347; `TryRelaunchFromHistoryAsync` ~line 5171) +- Modify: `tests/CodeShellManager.Tests/SearchServiceTests.cs` + +**Interfaces:** +- Produces: column `session_history.snapshot_json TEXT NULL`, added by `InitializeSchemaAsync` via `ALTER TABLE` when `PRAGMA table_info(session_history)` lacks it. +- Produces: `SessionHistoryEntry` gains a trailing `string? SnapshotJson = null` positional parameter. +- Produces: `RecordSessionHistoryAsync(..., string groupId, string? snapshotJson = null)`. + +- [ ] **Step 1: Tests** + +Append to `SearchServiceTests`: + +```csharp + [Fact] + public async Task SessionHistory_RoundTripsSnapshotJson() + { + await _svc.RecordSessionHistoryAsync("sid-1", "n", @"\\wsl$\Ubuntu\home\a", "claude", "", "", "{\"Kind\":2}"); + var e = await _svc.GetSessionHistoryAsync("sid-1"); + Assert.NotNull(e); + Assert.Equal("{\"Kind\":2}", e!.SnapshotJson); + } + + [Fact] + public async Task SessionHistory_WithoutSnapshot_ReadsNull() + { + await _svc.RecordSessionHistoryAsync("sid-2", "n", @"C:\p", "claude", "", ""); + var e = await _svc.GetLatestSessionHistoryForFolderAsync(@"C:\p"); + Assert.Null(e!.SnapshotJson); + } + + [Fact] + public async Task InitializeSchema_UpgradesPreSnapshotTable() + { + // Simulate a database created by the previous release: same table without the column. + string path = Path.Combine(Path.GetTempPath(), $"csm-hist-{Guid.NewGuid():N}.db"); + var db = new SqliteConnection($"Data Source={path}"); + db.Open(); + try + { + await using (var cmd = db.CreateCommand()) + { + cmd.CommandText = """ + CREATE TABLE session_history ( + id INTEGER PRIMARY KEY, session_id TEXT NOT NULL, session_name TEXT NOT NULL, + working_folder TEXT NOT NULL, command TEXT NOT NULL, args TEXT NOT NULL DEFAULT '', + group_id TEXT NOT NULL DEFAULT '', exited_at INTEGER NOT NULL); + INSERT INTO session_history (session_id, session_name, working_folder, command, exited_at) + VALUES ('old', 'o', 'C:\x', 'bash', 1); + """; + await cmd.ExecuteNonQueryAsync(); + } + await SearchService.InitializeSchemaAsync(db); + await SearchService.InitializeSchemaAsync(db); // idempotent + var svc = new SearchService(db); + var e = await svc.GetSessionHistoryAsync("old"); + Assert.NotNull(e); + Assert.Null(e!.SnapshotJson); + } + finally + { + db.Close(); db.Dispose(); SqliteConnection.ClearAllPools(); + try { File.Delete(path); } catch { } + } + } +``` + +- [ ] **Step 2: Run → compile error (`SnapshotJson`).** + +- [ ] **Step 3: Implement `SearchService`** + +Record: `public record SessionHistoryEntry(string SessionId, string SessionName, string WorkingFolder, string Command, string Args, string GroupId, DateTime ExitedAt, string? SnapshotJson = null);` + +Add `snapshot_json TEXT NULL` to the `CREATE TABLE IF NOT EXISTS session_history` DDL (fresh databases). After the big DDL `ExecuteNonQueryAsync`, add a guarded upgrade for existing databases: + +```csharp + // Column added after the first release of session_history; CREATE TABLE IF NOT + // EXISTS won't touch an existing table, so upgrade explicitly. Idempotent. + bool hasSnapshot = false; + await using (var probe = db.CreateCommand()) + { + probe.CommandText = "PRAGMA table_info(session_history)"; + await using var r = await probe.ExecuteReaderAsync(); + while (await r.ReadAsync()) + if (string.Equals(r.GetString(1), "snapshot_json", StringComparison.OrdinalIgnoreCase)) hasSnapshot = true; + } + if (!hasSnapshot) + { + await using var alter = db.CreateCommand(); + alter.CommandText = "ALTER TABLE session_history ADD COLUMN snapshot_json TEXT NULL"; + await alter.ExecuteNonQueryAsync(); + } +``` + +`RecordSessionHistoryAsync`: add parameter `string? snapshotJson = null`, add `snapshot_json` to the column list and `$snap` to VALUES; `cmd.Parameters.AddWithValue("$snap", (object?)snapshotJson ?? DBNull.Value);`. + +Both readers: select `snapshot_json` as the 8th column and pass `r.IsDBNull(7) ? null : r.GetString(7)`. + +- [ ] **Step 4: Implement `MainWindow`** + +In the `pty.Exited` handler (~line 1347) pass the snapshot: + +```csharp + _ = _searchService.RecordSessionHistoryAsync( + session.Id, session.Name, session.WorkingFolder, + session.Command, session.Args, session.GroupId, + System.Text.Json.JsonSerializer.Serialize(Models.RecentlyClosedEntry.FromSession(session))); +``` + +In `TryRelaunchFromHistoryAsync`, replace the final three lines (`CreateSession … SeedRunCommandsAsync … LaunchSessionAsync`) with: + +```csharp + // Prefer the full snapshot: it carries Kind and the SSH/WSL fields, so a WSL session + // relaunches as WSL instead of Local-at-a-UNC. Rows from before the column exist + // without one and fall back to the kind-agnostic columns. + Models.RecentlyClosedEntry? snapshot = null; + if (!string.IsNullOrEmpty(entry.SnapshotJson)) + { + try { snapshot = System.Text.Json.JsonSerializer.Deserialize(entry.SnapshotJson); } + catch (System.Text.Json.JsonException ex) { Log($"History snapshot unreadable for '{entry.SessionId}': {ex.Message}"); } + } + if (snapshot != null) + { + snapshot.MigrateLegacyFields(); + await ReopenClosedSessionAsync(snapshot); + return; + } + + var newSession = _sessionManager.CreateSession( + entry.SessionName, entry.WorkingFolder, entry.Command, entry.Args, entry.GroupId); + SeedRunCommandsAsync(newSession); + await LaunchSessionAsync(newSession); +``` + +- [ ] **Step 5: Build + full tests → commit** + +```bash +git add -A +git commit -m "fix(search): relaunching a closed session from a search hit keeps its kind + +session_history gains a snapshot_json column holding the RecentlyClosedEntry; +relaunch goes through ReopenClosedSessionAsync so WSL/SSH sessions come back +as themselves. Existing databases are upgraded in InitializeSchemaAsync. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 9: New Session dialog — no re-entrancy during the WSL home probe + +**Why:** `Start_Click` is `async void` and awaits a wsl.exe spawn (up to 3 s) with the default button still enabled. A second Enter runs a second probe and then sets `DialogResult` on a closed window (InvalidOperationException, logged as UNHANDLED). `BrowseWslFolder_Click` can pop its picker after the dialog closed. + +**Files:** +- Modify: `src/CodeShellManager/Views/NewSessionDialog.xaml.cs` (`Start_Click`, `BrowseWslFolder_Click`, constructor) + +- [ ] **Step 1: Add state + Closed hook** + +Fields: `private bool _submitting; private bool _closed;` In the constructor after `InitializeComponent();`: `Closed += (_, _) => _closed = true;` + +- [ ] **Step 2: Guard `Start_Click`** + +Wrap the body: at the top `if (_submitting) return; _submitting = true; OkButton.IsEnabled = false; try { …existing body… } finally { if (!_closed) { _submitting = false; OkButton.IsEnabled = true; } }`. Immediately after the `await WslDiscoveryService.GetDistroHomeAsync(...)` line add `if (_closed) return;`. Every early `return` inside the body (validation failures) now exits through `finally`, which re-enables the button — that is the desired behaviour. + +- [ ] **Step 3: Guard `BrowseWslFolder_Click`** + +After `string seed = await ComputeWslBrowseSeedAsync(...)` add `if (_closed) return;`. + +- [ ] **Step 4: Build + full tests → commit** + +```bash +git add -A +git commit -m "fix(new-session): no double submit while the WSL home probe runs + +Start_Click disables the primary button for the duration and bails out if the +window closed during the await, so a second Enter or a Cancel can't set +DialogResult on a closed window. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 10: Documentation — CLAUDE.md + +**Files:** +- Modify: `CLAUDE.md` (Services table line ~81; "Session Lifecycle" steps 1-2 lines ~154-155; "Editing a Session's Configuration" lines 171-226; add "## WSL Sessions" after "SSH Remote Sessions" (ends ~line 238); "Recently Closed Sessions" ~260; "Per-Session Run Commands" `Mode` bullet ~328; "Color / Theme" accent key ~131) + +- [ ] **Step 1: Services table** — add a row after `ShellIntegrationPayload`: + +``` +| `WslDiscoveryService` | `wsl -l -v` parsing (UTF-16, header skipped, `*` default marker, names with spaces), `GetDistroHomeAsync` (cached `cd ~ && pwd` per distro+user), `ToUncPath` / `TryParseUncPath` — the **only** UNC↔Linux path converters; GitService and NewSessionDialog delegate here | +``` + +- [ ] **Step 2: Session Lifecycle** — step 1 becomes "… `NewSessionDialog` modal (Local, Remote SSH, or WSL)"; step 2 "… caller sets `Kind` and copies SSH or WSL fields; for WSL it also sets `WorkingFolder` to the `\\wsl$\\` UNC mirror (see 'WSL Sessions')". + +- [ ] **Step 3: Editing a Session's Configuration** — in the dialog bullets add "Local/Remote/WSL radio … WSL distro (pre-selected once the async list loads; a since-uninstalled distro is kept as a `(not installed)` entry so Save cannot wipe it), WSL user and Linux folder are all pre-filled. The appearance panel is shown for Local and WSL, hidden only for SSH." In the "Result plumbing" bullets: `Diff` also reports `WorkingFolderChanged` for a WSL distro/Linux-folder change; `Apply(session, draft)` writes `Kind` directly and **re-derives the UNC `WorkingFolder`** for WSL. In "What needs a restart": replace "local↔remote flip" with "any `Kind` change" and add "any WSL field change (distro, user, Linux folder)". + +- [ ] **Step 4: New section after "SSH Remote Sessions"** + +```markdown +## WSL Sessions + +`SessionKind.Wsl` launches `wsl.exe -d [-u ] --cd -- bash -lc ""` (PR #65, hardened on `feat/wsl-sessions-v2`). + +- **`Kind` is the only persisted discriminator.** `IsRemote` is a `[JsonIgnore]`d two-way convenience over `Kind` (`false` on an SSH session makes it Local; a WSL session is untouched). The legacy `"IsRemote"` JSON key lands in `LegacyIsRemote` and `StateService.Normalize` folds it into `Kind` — migration lives in the loader, never in a setter. A promote-only setter was tried first and silently broke "Edit session" (SSH→Local could not demote) and then every save (`FullCommandLine` was serialised and threw); see `ShellSessionMigrationTests`. +- **UNC mirror invariant.** A WSL session stores `WorkingFolder = WslDiscoveryService.ToUncPath(WslDistro, WslWorkingFolder)` (`\\wsl$\Ubuntu\home\alice\proj`). Explorer, the dormant row, run-command template seeding and `GitService` all work off that path unchanged. The Linux-side path lives on `WslWorkingFolder` and is what `--cd` receives. Every path that creates or edits a WSL session must keep both in step: `MainWindow` session creation, `InheritSessionKindFrom` (duplicate / worktree), `SessionConfigEditor.Apply`, `ReopenClosedSessionAsync`. +- **GitService routing.** `RunGitFullAsync` detects the UNC and runs `wsl.exe -d -- git -C …`, translating `\\wsl$` args to Linux (`TranslateUncArgsToLinux`, with a distro-name boundary so `Ubuntu` never matches `Ubuntu-22.04`) and Linux paths in stdout back to UNC. `SessionViewModel.RefreshGitInfoAsync` runs the probe on the thread pool (a `Directory.Exists` on `\\wsl$` boots a stopped distro and used to freeze the UI), polls WSL sessions every **30 s** (10 s local) and caches a "not a repo" answer for WSL so it does not spawn `wsl.exe` for it forever. +- **Quoting.** Everything that reaches `wsl.exe` goes through `ShellSession.QuoteForCmd` — MSVCRT rules (backslashes before a quote doubled, trailing backslashes doubled), verified by round-tripping through `CommandLineToArgvW` in `Win32CommandLineTests`. There is one `BuildWslArgs` (`ShellSession.BuildWslArgs(string? inner)`); `RunInstance` delegates to it and turns a build failure into a failed run chip rather than a throw. +- **Validation before UI.** `ShellSession.LaunchValidationError` (blank distro / blank SSH host) is checked at the top of `LaunchSessionAsync` before any WebView2 exists. `state.json` and imports are untrusted; a bad entry used to leak a pane. +- **Relaunch paths.** `RecentlyClosedEntry` and the `session_history.snapshot_json` column carry `Kind` + WSL fields, so Ctrl+Shift+T, the "Recently closed" list and relaunch-from-search all restore the right kind. +- **Known gaps:** WSL Claude sessions do not auto-resume on restore (`--resume` id lookup reads the Windows `~/.claude`); a distro name beginning with `-` is not defended against in `wsl.exe` option parsing. +``` + +- [ ] **Step 5: Per-Session Run Commands** — change "SSH parents ignore `Mode` — remote runs always go through bash." to "SSH and WSL parents ignore `Mode` — those runs always go through bash (`ssh … bash -c` / `wsl.exe … bash -lc`)." + +- [ ] **Step 6: Color / Theme** — "For local sessions the key is `WorkingFolder`; for SSH sessions `user@host`; for WSL `wsl://`." + +- [ ] **Step 7: Recently Closed Sessions** — add one sentence: "Entries carry `Kind` and the SSH/WSL fields; legacy entries are migrated by `StateService.Normalize` like sessions." + +- [ ] **Step 8: Verify** — `grep -n IsRemote CLAUDE.md`; every remaining mention must describe the convenience property or the legacy key, not a branch point. + +- [ ] **Step 9: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: WSL sessions section; Kind replaces IsRemote throughout CLAUDE.md + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu" +``` + +--- + +### Task 11: Final gates + +- [ ] **Step 1:** `dotnet build src/CodeShellManager/CodeShellManager.csproj -nologo -c Release 2>&1 | grep -E "warning|error|Build succeeded"` — no new warnings vs. `origin/main`. +- [ ] **Step 2:** `dotnet test tests/CodeShellManager.Tests/ -nologo -v q` — all pass; record the count. +- [ ] **Step 3:** `git log --oneline origin/main..HEAD` — every commit carries the trailer. +- [ ] **Step 4:** Dispatch a read-only review agent against `origin/main...HEAD` with the ten original findings as a checklist; fix anything CONFIRMED, re-run gates. +- [ ] **Step 5:** Push `feat/wsl-sessions-v2`, open the PR against `main` referencing #65 and crediting the contributor; body lists each finding → fix → test. diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 3d2300e..a28e71b 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -458,7 +458,10 @@ private void BuildShutdownBoard(IReadOnlyList sessions) var name = new TextBlock { - Text = string.IsNullOrWhiteSpace(vm.Name) ? vm.Command : vm.Name, + // DisplayName, not Command: a WSL session can legitimately carry a blank + // Command (the dialog stores "" when the shell box is empty, so the login + // shell is resolved at launch), which used to render an empty row label. + Text = vm.DisplayName, Foreground = new SolidColorBrush(Color.FromRgb(0x6c, 0x70, 0x86)), FontFamily = new FontFamily("Segoe UI"), FontSize = 11.5, @@ -644,7 +647,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 }; @@ -693,12 +697,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. @@ -732,11 +748,18 @@ private async Task ReopenClosedSessionAsync(RecentlyClosedEntry en string.IsNullOrEmpty(entry.GroupId) ? null : entry.GroupId, colorOverride: entry.ColorOverride); - session.IsRemote = entry.IsRemote; + session.Kind = entry.Kind; 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; + // A hand-edited or stale RecentlyClosed entry can carry a WorkingFolder that no + // longer matches WslDistro/WslWorkingFolder (see CLAUDE.md "WSL Sessions" — the + // UNC mirror invariant). Re-derive rather than trust the snapshot's WorkingFolder. + Services.WslDiscoveryService.ResyncWslWorkingFolder(session); session.ProfileFontFamily = entry.ProfileFontFamily; session.ProfileFontSize = entry.ProfileFontSize; @@ -809,6 +832,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; @@ -843,14 +867,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; @@ -895,6 +912,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. @@ -916,6 +982,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; @@ -938,7 +1005,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; @@ -1189,6 +1261,61 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal bool removeOnFailure = true) { Log($"LaunchSession START: cmd='{session.Command}' args='{session.Args}' folder='{session.WorkingFolder}' restoring={restoring}"); + + if (session.LaunchValidationError is { } validationError) + { + Log($"LaunchSession REFUSED: {validationError}"); + string label = string.IsNullOrWhiteSpace(session.Name) ? session.DefaultDisplayName : session.Name; + MessageBox.Show(this, $"Cannot start '{label}'.\n\n{validationError}", + "Launch Error", MessageBoxButton.OK, MessageBoxImage.Warning); + // Mirrors the PTY-failure catch below: dormant fallback is the caller's job. + // RestartSessionAsync (the only removeOnFailure: false caller) already checks + // _sessionUi after the await and adds the dormant row itself — doing it here + // too would leave a second, untracked Border in the sidebar. + if (removeOnFailure) _sessionManager.RemoveSession(session.Id); + if (_launchingSidebarItems.Remove(session.Id)) RebuildSidebarOrder(); + return; + } + + if (session.Kind == Models.SessionKind.Wsl) + { + // Resolve before BuildWslArgs runs below — minimal distros (Alpine, BusyBox + // images, Docker Desktop's own distro) have no bash, so hardcoding it fails + // every launch there. Run commands inherit this via the same ShellSession + // instance (RunInstance.BuildWslArgs delegates to session.BuildWslArgs). + // Started, not awaited: the home lookup below doesn't depend on it, and each + // probe carries its own 3s timeout — serialising them doubled the worst case + // on a cold launch, once per session during a restore. + var shellTask = WslDiscoveryService.GetLoginShellAsync(session.WslDistro, session.WslUser); + + // Two folder shapes need $HOME resolved. Blank: the dialog resolves it eagerly, + // but that probe is capped at 3s and a cold distro (first launch after install) + // blows through it, leaving WorkingFolder at the distro ROOT — so git status, the + // sidebar subtitle and "Open in Explorer" aimed at / while the shell sat in $HOME. + // Leading ~: wsl.exe only special-cases the bare `~` token, so a typed "~/proj" + // is treated as an absolute *Windows* path and fails the launch outright. Both + // are fixed here, where the distro is starting anyway; GetDistroHomeAsync caches + // only successes, so this genuinely re-probes after an earlier timeout. + string wslFolder = (session.WslWorkingFolder ?? "").Trim(); + bool needsHome = wslFolder.Length == 0 + || wslFolder == "~" + || wslFolder.StartsWith("~/", StringComparison.Ordinal); + if (needsHome) + { + string? home = await WslDiscoveryService.GetDistroHomeAsync(session.WslDistro, session.WslUser); + if (!string.IsNullOrEmpty(home)) + { + session.WslWorkingFolder = wslFolder.StartsWith("~/", StringComparison.Ordinal) + ? home.TrimEnd('/') + wslFolder[1..] + : home; + WslDiscoveryService.ResyncWslWorkingFolder(session); + _ = _vm.SaveStateAsync(); + } + } + + session.ResolvedWslShell = await shellTask; + } + var vm = new SessionViewModel(session); // Set up alert detection @@ -1257,9 +1384,12 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal string htmlFile = wantTransparent ? "terminal-transparent.html" : "terminal.html"; string htmlPath = new Uri(Path.Combine(assetsDir, htmlFile)).AbsoluteUri; - string bootLabel = session.IsRemote - ? $"Connecting to {session.SshHost}…" - : $"Starting {(string.IsNullOrWhiteSpace(session.Command) ? "session" : session.Command)}…"; + string bootLabel = session.Kind switch + { + Models.SessionKind.Ssh => $"Connecting to {session.SshHost}…", + Models.SessionKind.Wsl => $"Starting {session.WslDistro}…", + _ => $"Starting {(string.IsNullOrWhiteSpace(session.Command) ? "session" : session.Command)}…", + }; bridge.SetBootContext(bootLabel, GetAccentForSession(session)); await bridge.InitializeAsync(htmlPath); bridge.ApplyFontSettings(_vm.Settings); @@ -1274,9 +1404,16 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal { if (_searchService != null) { + // RunCommands is a plain List mutated on the UI thread (SeedRunCommandsAsync, + // the run-commands editor save handler); pty.Exited fires on a background + // thread with no marshaling, so snapshot it on the dispatcher rather than + // enumerating it from here. + string snapshotJson = Dispatcher.Invoke(() => + System.Text.Json.JsonSerializer.Serialize(Models.RecentlyClosedEntry.FromSession(session))); _ = _searchService.RecordSessionHistoryAsync( session.Id, session.Name, session.WorkingFolder, - session.Command, session.Args, session.GroupId); + session.Command, session.Args, session.GroupId, + snapshotJson); if (sessionStartUtc != DateTime.MinValue && !string.IsNullOrEmpty(usageCommandKey)) { long secs = (long)(DateTime.UtcNow - sessionStartUtc).TotalSeconds; @@ -1294,12 +1431,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) @@ -3061,6 +3207,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()); @@ -3248,6 +3401,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; @@ -4484,7 +4638,7 @@ private async Task EditSessionAsync(SessionViewModel vm) var change = Services.SessionConfigEditor.Diff(session, draft); if (!change.AnyChange) return; - bool wasRemote = session.IsRemote; + var wasKind = session.Kind; // Apply mutates session.Command in place, so capture what the RUNNING process was // launched with before that happens. RestartSessionAsync needs the OLD command to // decide whether the outgoing process is a Claude that has to be waited out — @@ -4494,7 +4648,7 @@ private async Task EditSessionAsync(SessionViewModel vm) Services.SessionConfigEditor.Apply(session, draft); vm.NotifyConfigChanged(); - if (change.WorkingFolderChanged || session.IsRemote != wasRemote) + if (change.WorkingFolderChanged || session.Kind != wasKind) _ = vm.ReloadGitInfoAsync(); // No-op when the session carries no overrides; re-asserting the global font first @@ -4785,9 +4939,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 @@ -4799,11 +4951,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 { @@ -4880,9 +5028,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 @@ -4894,11 +5040,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 { @@ -4996,10 +5138,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 ──────────────────────────────────────────────────────────────── @@ -5095,6 +5234,22 @@ private async Task TryRelaunchFromHistoryAsync(string? sessionId, string? folder "Relaunch Session?", MessageBoxButton.YesNo, MessageBoxImage.Question); if (answer != MessageBoxResult.Yes) return; + // Prefer the full snapshot: it carries Kind and the SSH/WSL fields, so a WSL session + // relaunches as WSL instead of Local-at-a-UNC. Rows from before the column exist + // without one and fall back to the kind-agnostic columns. + Models.RecentlyClosedEntry? snapshot = null; + if (!string.IsNullOrEmpty(entry.SnapshotJson)) + { + try { snapshot = System.Text.Json.JsonSerializer.Deserialize(entry.SnapshotJson); } + catch (System.Text.Json.JsonException ex) { Log($"History snapshot unreadable for '{entry.SessionId}': {ex.Message}"); } + } + if (snapshot != null) + { + snapshot.MigrateLegacyFields(); + await ReopenClosedSessionAsync(snapshot); + return; + } + var newSession = _sessionManager.CreateSession( entry.SessionName, entry.WorkingFolder, entry.Command, entry.Args, entry.GroupId); SeedRunCommandsAsync(newSession); @@ -5188,6 +5343,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.WorkingFolder is already parent.WorkingFolder — a known-good WSL UNC — + // so InheritSessionKindFrom's "common path" branch re-derives WslWorkingFolder from + // it, which is a straight subset of the hand-copied assignments this replaces. + InheritSessionKindFrom(session, parent); + _ = LaunchSessionAsync(session); + } + private static bool ExistsOnPath(string executable) { try diff --git a/src/CodeShellManager/Models/RecentlyClosedEntry.cs b/src/CodeShellManager/Models/RecentlyClosedEntry.cs index ddff9b6..e5061cf 100644 --- a/src/CodeShellManager/Models/RecentlyClosedEntry.cs +++ b/src/CodeShellManager/Models/RecentlyClosedEntry.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; namespace CodeShellManager.Models; @@ -23,12 +24,47 @@ public class RecentlyClosedEntry public string GroupId { get; set; } = ""; public string? ColorOverride { get; set; } - public bool IsRemote { get; set; } + /// + /// Kind of the closed session, so a reopened WSL or SSH session comes back as the same + /// kind instead of Local at a UNC. Legacy entries carried only IsRemote; see + /// . + /// + public SessionKind Kind { get; set; } = SessionKind.Local; + + [JsonIgnore] + public bool IsRemote => Kind == SessionKind.Ssh; + + /// + /// Legacy "IsRemote" JSON slot — same computed-getter / backing-field-setter + /// split as (see there for the full rationale): + /// written as true for an SSH entry, omitted for Local/Wsl, so a rollback reading + /// this file still sees SSH entries as remote. + /// + [JsonPropertyName("IsRemote")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? LegacyIsRemote + { + get => Kind == SessionKind.Ssh ? true : (bool?)null; + set => _legacyIsRemoteIncoming = value; + } + + private bool? _legacyIsRemoteIncoming; + + public void MigrateLegacyFields() + { + if (_legacyIsRemoteIncoming == true && Kind == SessionKind.Local) Kind = SessionKind.Ssh; + _legacyIsRemoteIncoming = null; + } + 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 +93,14 @@ public class RecentlyClosedEntry Args = s.Args, GroupId = s.GroupId, ColorOverride = s.ColorOverride, - IsRemote = s.IsRemote, + Kind = s.Kind, 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 +123,14 @@ 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. + [JsonIgnore] + 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/SessionConfigDraft.cs b/src/CodeShellManager/Models/SessionConfigDraft.cs index 88ae9bf..e556b47 100644 --- a/src/CodeShellManager/Models/SessionConfigDraft.cs +++ b/src/CodeShellManager/Models/SessionConfigDraft.cs @@ -20,12 +20,16 @@ public sealed class SessionConfigDraft public string Command { get; set; } = ""; public string Args { get; set; } = ""; - // Remote - public bool IsRemote { get; set; } + // Kind + kind-specific fields. Kind is authoritative; IsRemote is a read-only view. + public SessionKind Kind { get; set; } = SessionKind.Local; + public bool IsRemote => Kind == SessionKind.Ssh; public string SshUser { get; set; } = ""; public string SshHost { get; set; } = ""; public int SshPort { get; set; } = 22; public string SshRemoteFolder { get; set; } = ""; + public string WslDistro { get; set; } = ""; + public string WslUser { get; set; } = ""; + public string WslWorkingFolder { get; set; } = ""; // Appearance overrides — null means "use the global terminal settings" public string? ProfileFontFamily { get; set; } @@ -46,7 +50,10 @@ public sealed class SessionConfigDraft WorkingFolder = s.WorkingFolder, Command = s.Command, Args = s.Args, - IsRemote = s.IsRemote, + Kind = s.Kind, + WslDistro = s.WslDistro, + WslUser = s.WslUser, + WslWorkingFolder = s.WslWorkingFolder, SshUser = s.SshUser, SshHost = s.SshHost, SshPort = s.SshPort, diff --git a/src/CodeShellManager/Models/ShellSession.cs b/src/CodeShellManager/Models/ShellSession.cs index de82a4b..25e2c6c 100644 --- a/src/CodeShellManager/Models/ShellSession.cs +++ b/src/CodeShellManager/Models/ShellSession.cs @@ -1,11 +1,19 @@ using System; using System.Collections.Generic; using System.Text; +using System.Text.Json.Serialization; 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 +42,97 @@ public class ShellSession /// public bool IsDormant { get; set; } + /// + /// Authoritative session kind. Everything that branches on session type reads this. + /// Legacy state.json files (pre-Kind) only carried IsRemote; see + /// and . + /// + public SessionKind Kind { get; set; } = SessionKind.Local; + + /// + /// Convenience view of for the SSH case. Setting true makes + /// the session SSH; setting false on an SSH session makes it Local. It is + /// deliberately NOT persisted — is — and it carries no migration + /// logic. A WSL session is unaffected by IsRemote = false. + /// + [JsonIgnore] + public bool IsRemote + { + get => Kind == SessionKind.Ssh; + set + { + if (value) Kind = SessionKind.Ssh; + else if (Kind == SessionKind.Ssh) Kind = SessionKind.Local; + } + } + + /// + /// Compatibility slot for the pre- "IsRemote" JSON key. + /// origin/main persists only this key and has no at all — an + /// older build reading a state.json written by this one must still see an SSH + /// session as remote, so the getter is computed from rather than + /// left write-only: true for , otherwise null + /// (omitted from the JSON entirely via + /// — Local/Wsl sessions never carry this key). + /// + /// The setter does NOT share storage with the getter: during deserialization of an old + /// file it only records the incoming legacy value into ; + /// folds that into and clears it. A + /// computed getter has no state to "null itself out" after migration — once Kind is Ssh, + /// this property reads true again, which is correct (nothing left to migrate). + /// + [JsonPropertyName("IsRemote")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? LegacyIsRemote + { + get => Kind == SessionKind.Ssh ? true : (bool?)null; + set => _legacyIsRemoteIncoming = value; + } + + private bool? _legacyIsRemoteIncoming; + + /// + /// Folds legacy JSON fields into their current representation. Idempotent. Called by + /// StateService.Normalize for every loaded or imported session — the loader is + /// the one place that knows it is looking at possibly-old data. + /// + public void MigrateLegacyFields() + { + if (_legacyIsRemoteIncoming == true && Kind == SessionKind.Local) Kind = SessionKind.Ssh; + _legacyIsRemoteIncoming = null; + } + + /// True iff this session runs inside a WSL distro via wsl.exe. + [JsonIgnore] + public bool IsWsl => Kind == SessionKind.Wsl; + // SSH / remote session fields - public bool IsRemote { get; set; } 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; } = ""; + + /// + /// Runtime-only cache of the distro's login shell ("bash" or "sh"), resolved by + /// in + /// MainWindow.LaunchSessionAsync before is called. + /// Deliberately NOT persisted — a distro's available shells can change between runs + /// (e.g. a minimal image gains bash after an update), so this is always re-probed at + /// launch rather than trusted from a prior session. Run commands share this via the + /// same instance, so they never re-probe. Null until resolved, + /// in which case falls back to "bash". + /// + [JsonIgnore] + internal string? ResolvedWslShell { get; set; } + // Per-session appearance overrides (typically populated from a Windows // Terminal profile via NewSessionDialog). All nullable — null means "use the // global terminal settings". Persisted to state.json so a session relaunches @@ -65,12 +157,32 @@ 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}"); + /// + /// Full command line for display. Never throws: an incomplete session (blank SSH host, + /// blank WSL distro) shows just the executable — this string is used in error dialogs + /// on exactly those paths. + /// + [JsonIgnore] + public string FullCommandLine => Kind switch + { + SessionKind.Ssh => string.IsNullOrWhiteSpace(SshHost) ? "ssh" : $"ssh {BuildSshArgs()}", + SessionKind.Wsl => string.IsNullOrWhiteSpace(WslDistro) ? "wsl.exe" : $"wsl.exe {BuildWslArgs()}", + _ => string.IsNullOrWhiteSpace(Args) ? Command : $"{Command} {Args}", + }; + + /// + /// Null when the session has everything it needs to launch; otherwise a sentence for + /// the user. Checked at the top of MainWindow.LaunchSessionAsync BEFORE any + /// WebView2 or PTY is created, because the arg builders throw on these and a throw at + /// that point leaks the pane. state.json and imports are untrusted input. + /// + [JsonIgnore] + public string? LaunchValidationError => Kind switch + { + SessionKind.Ssh when string.IsNullOrWhiteSpace(SshHost) => "This SSH session has no host. Edit the session and set one.", + SessionKind.Wsl when string.IsNullOrWhiteSpace(WslDistro) => "This WSL session has no distro. Edit the session and pick one.", + _ => null, + }; /// /// Builds the argument string passed to the ssh executable. @@ -96,4 +208,143 @@ internal string BuildSshArgs() sb.Append("\""); return sb.ToString(); } + + /// + /// Win32 (MSVCRT / CommandLineToArgvW) argument quoting. Space-free, quote-free values + /// are returned unchanged unless is set. Inside quotes, a + /// run of n backslashes followed by " becomes 2n+1 backslashes + quote, and a + /// trailing run of n backslashes becomes 2n so it cannot eat the closing quote. + /// Every value that reaches wsl.exe goes through here — no ad-hoc Replace. + /// + internal static string QuoteForCmd(string value, bool force = false) + { + value ??= ""; + if (!force && value.Length > 0 && value.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) + return value; + + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + int backslashes = 0; + foreach (char c in value) + { + if (c == '\\') { backslashes++; continue; } + if (c == '"') + { + sb.Append('\\', backslashes * 2 + 1).Append('"'); + backslashes = 0; + continue; + } + sb.Append('\\', backslashes).Append(c); + backslashes = 0; + } + sb.Append('\\', backslashes * 2); + sb.Append('"'); + return sb.ToString(); + } + + /// + /// Builds the argument string passed to wsl.exe: + /// -d <distro> [-u <user>] [--cd <linux-folder>] -e <shell> -lc "<payload>". + /// The payload is + , or + /// when given (run commands). It is wrapped in <shell> -lc so PATH-resolved + /// tools (nvm node, pyenv, …) behave as in a login shell; the shell then interprets the + /// payload as a shell command line, which is the intent. Shell is + /// when set (probed per-distro by + /// ), else "bash". + /// + /// -e (not --) is deliberate: wsl.exe <cmd> -- … runs the + /// trailing command through the distro's *default* login shell before anything after + /// -- ever runs, so a payload built for our shell gets expanded twice — once by + /// that default shell (in the wrong environment) and once by ours. -e/--exec + /// executes the given program directly, skipping that first pass. -- looks more + /// natural here — resist the urge to change it back; verified empirically: + /// wsl -d Ubuntu -- bash -lc 'for t in a b; do echo "L=$t"; done' printed + /// "L=" / "L=" (mangled) while the same command with -e bash printed "L=a" / "L=b". + /// + /// Throws when is blank — callers validate first + /// (). + /// + internal string BuildWslArgs(string? inner = null) + { + if (string.IsNullOrWhiteSpace(WslDistro)) + throw new InvalidOperationException("WslDistro must be set for WSL sessions."); + string loginShell = ResolvedWslShell ?? "bash"; + var sb = new StringBuilder(); + sb.Append("-d ").Append(QuoteForCmd(WslDistro)); + if (!string.IsNullOrWhiteSpace(WslUser)) + sb.Append(" -u ").Append(QuoteForCmd(WslUser)); + // A blank folder means "the user's home" — that is what the dialog's "(optional)" + // label promises. `--cd ~` is wsl.exe's own spelling for it and honours -u. Omitting + // --cd entirely does NOT do this: wsl then inherits the launching *Windows* process's + // cwd and lands the session in /mnt/c/... on the slow 9p mount. Pass ~ unquoted; + // quoting it would make it a literal directory name. + sb.Append(" --cd ").Append(string.IsNullOrWhiteSpace(WslWorkingFolder) + ? "~" + : QuoteForCmd(WslWorkingFolder)); + if (inner is null) + { + var shell = string.IsNullOrWhiteSpace(Command) ? loginShell : Command; + inner = string.IsNullOrWhiteSpace(Args) ? shell : $"{shell} {Args}"; + } + sb.Append(" -e ").Append(QuoteForCmd(loginShell)).Append(" -lc ").Append(QuoteForCmd(inner, force: true)); + return sb.ToString(); + } + + // ── Display helpers (single source of truth — see MainWindow sidebar / VM) ──── + + /// + /// Subtitle-line text for the sidebar: a short, kind-appropriate locator. + /// Local → working folder leaf; Ssh → host; Wsl → distro:linux-leaf. + /// + [JsonIgnore] + public string FolderShort => Kind switch + { + SessionKind.Ssh => string.IsNullOrWhiteSpace(SshHost) ? "" : SshHost, + SessionKind.Wsl => BuildWslFolderShort(), + _ => string.IsNullOrEmpty(WorkingFolder) + ? "" + // DirectoryInfo(...).Name throws ArgumentException on a path containing an + // embedded NUL — reachable from state.json on the restore path. Path.GetFileName + // (what DefaultDisplayName already uses) tolerates it. + : System.IO.Path.GetFileName(WorkingFolder.TrimEnd('/', '\\')), + }; + + /// + /// What to show as the session's label when is blank. + /// + [JsonIgnore] + 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. + /// + [JsonIgnore] + 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..b8b7ac0 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,113 @@ 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. + /// Delegates to — the single + /// parser for this shape shared with NewSessionDialog. + /// + internal static (string? distro, string linuxPath) TryParseWslUnc(string path) + => WslDiscoveryService.TryParseUncPath(path); + + /// + /// 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); + // Lookahead: the distro name must be followed by a separator, a quote, whitespace or + // end-of-string — otherwise `Ubuntu` also matches `Ubuntu-22.04`. + string body = $@"\\\\wsl(?:\$|\.localhost)\\{esc}(?=[\\""\s]|$)"; + + // Pass 1: quoted UNCs ("\\wsl$\\..."). The tail may contain spaces + // and runs until the closing quote — without this pass, the unquoted regex + // 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 5363339..6361f7a 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) { @@ -89,32 +90,78 @@ public void Start(ShellSession parent) _pty.DataReceived += OnPtyData; _pty.Exited += OnPtyExited; - string command, args, workDir; - if (parent.IsRemote) + try { - // SSH parents always go through bash — Mode is meaningless for remote runs. - command = "ssh"; - args = BuildSshArgs(parent, CommandLine); - workDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string command, args, workDir; + switch (parent.Kind) + { + case SessionKind.Ssh: + // SSH parents always go through bash — Mode is meaningless for remote runs. + command = "ssh"; + args = BuildSshArgs(parent, CommandLine); + workDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + break; + case SessionKind.Wsl: + // WSL parents wrap the command in `wsl.exe … -- bash -lc` — + // running pwsh inside WSL is out of scope so Mode is ignored here too. + command = "wsl.exe"; + args = BuildWslArgs(parent, CommandLine); + workDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + break; + default: + if (Mode == RunMode.PowerShell) + { + command = ResolvePwsh(); + args = BuildPwshArgs(CommandLine); + } + else + { + command = "cmd"; + args = BuildLocalCmd(CommandLine); + } + workDir = Directory.Exists(parent.WorkingFolder) + ? parent.WorkingFolder + : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + break; + } + + _pty.Start(command, args, workDir, cols: 200, rows: 50, useJobObject: true); } - else if (Mode == RunMode.PowerShell) + catch (Exception ex) { - command = ResolvePwsh(); - args = BuildPwshArgs(CommandLine); - workDir = Directory.Exists(parent.WorkingFolder) - ? parent.WorkingFolder - : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + // A run that can't even build its command line (e.g. blank WslDistro) must + // show as a failed chip, not throw out of the toolbar click. + AppendText($"Cannot start: {ex.Message}\r\n"); + ExitCode = -1; + EndedAt = DateTime.Now; + State = RunState.ExitedFailed; + StateChanged?.Invoke(); + _pty.DataReceived -= OnPtyData; + _pty.Exited -= OnPtyExited; + _pty.Dispose(); + _pty = null; } - else + } + + /// + /// Appends text to the ANSI-stripped output buffer under , + /// refreshes , and raises . + /// + private void AppendText(string text) + { + string snapshot; + lock (_bufLock) { - command = "cmd"; - args = BuildLocalCmd(CommandLine); - workDir = Directory.Exists(parent.WorkingFolder) - ? parent.WorkingFolder - : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + _ansiStripped.Append(text); + if (_ansiStripped.Length > MaxBufferChars) + _ansiStripped.Remove(0, _ansiStripped.Length - MaxBufferChars); + snapshot = _ansiStripped.ToString(); } - - _pty.Start(command, args, workDir, cols: 200, rows: 50, useJobObject: true); + OutputBuffer = snapshot; + // Marshal to UI thread is the consumer's responsibility — OutputChanged + // fires from the PTY read loop's thread (or, for the start-failure path, + // synchronously from Start). + OutputChanged?.Invoke(); } public void Stop() @@ -127,6 +174,14 @@ private void OnPtyData(string text) { // Strip ANSI for the readonly drawer view + clipboard. Match the // OutputIndexer regex so any visible quirks stay consistent across the app. + // Marshal to UI thread is the consumer's responsibility — OutputChanged + // fires from the PTY read loop's thread. + // + // Deliberately NOT routed through AppendText: the PTY read loop hands us + // 4KB chunks, and AppendText's OutputBuffer = ToString() snapshot would be + // a full copy of the (up to 1MB) buffer per chunk — LOH churn plus a + // PropertyChanged per chunk on a hot path. OutputBuffer has no consumers + // in the app (only tests) — the app reads SnapshotOutput() on demand. string stripped = AnsiPattern().Replace(text, ""); lock (_bufLock) { @@ -134,8 +189,6 @@ private void OnPtyData(string text) if (_ansiStripped.Length > MaxBufferChars) _ansiStripped.Remove(0, _ansiStripped.Length - MaxBufferChars); } - // Marshal to UI thread is the consumer's responsibility — OutputChanged - // fires from the PTY read loop's thread. OutputChanged?.Invoke(); } @@ -246,6 +299,14 @@ internal static string BuildSshArgs(ShellSession parent, string commandLine) return sb.ToString(); } + /// + /// wsl.exe args for a run inside the parent's distro. One implementation with the + /// session launcher — see — so the two can't + /// disagree about quoting or about a blank distro. + /// + internal static string BuildWslArgs(ShellSession parent, string commandLine) + => parent.BuildWslArgs(commandLine); + /// /// 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/SearchService.cs b/src/CodeShellManager/Services/SearchService.cs index 604f94c..c8f0b1c 100644 --- a/src/CodeShellManager/Services/SearchService.cs +++ b/src/CodeShellManager/Services/SearchService.cs @@ -26,7 +26,8 @@ public record SessionHistoryEntry( string Command, string Args, string GroupId, - DateTime ExitedAt); + DateTime ExitedAt, + string? SnapshotJson = null); public record UsageStat( string Command, @@ -89,7 +90,8 @@ CREATE TABLE IF NOT EXISTS session_history ( command TEXT NOT NULL, args TEXT NOT NULL DEFAULT '', group_id TEXT NOT NULL DEFAULT '', - exited_at INTEGER NOT NULL + exited_at INTEGER NOT NULL, + snapshot_json TEXT NULL ); CREATE INDEX IF NOT EXISTS ix_session_history_sid ON session_history(session_id); CREATE INDEX IF NOT EXISTS ix_session_history_folder ON session_history(working_folder); @@ -103,6 +105,23 @@ CREATE TABLE IF NOT EXISTS usage_stats ( ); """; await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + + // Column added after the first release of session_history; CREATE TABLE IF NOT + // EXISTS won't touch an existing table, so upgrade explicitly. Idempotent. + bool hasSnapshot = false; + await using (var probe = db.CreateCommand()) + { + probe.CommandText = "PRAGMA table_info(session_history)"; + await using var r = await probe.ExecuteReaderAsync(); + while (await r.ReadAsync()) + if (string.Equals(r.GetString(1), "snapshot_json", StringComparison.OrdinalIgnoreCase)) hasSnapshot = true; + } + if (!hasSnapshot) + { + await using var alter = db.CreateCommand(); + alter.CommandText = "ALTER TABLE session_history ADD COLUMN snapshot_json TEXT NULL"; + await alter.ExecuteNonQueryAsync(); + } } // ── Project notes ───────────────────────────────────────────────────────── @@ -228,14 +247,14 @@ private static string BuildNoteSnippet(string content, string query) public async Task RecordSessionHistoryAsync( string sessionId, string sessionName, string workingFolder, - string command, string args, string groupId) + string command, string args, string groupId, string? snapshotJson = null) { using var _dbLock = await DbGate.AcquireAsync().ConfigureAwait(false); await using var cmd = _db.CreateCommand(); cmd.CommandText = """ INSERT INTO session_history - (session_id, session_name, working_folder, command, args, group_id, exited_at) - VALUES ($sid, $name, $folder, $cmd, $args, $gid, $ts) + (session_id, session_name, working_folder, command, args, group_id, exited_at, snapshot_json) + VALUES ($sid, $name, $folder, $cmd, $args, $gid, $ts, $snap) """; cmd.Parameters.AddWithValue("$sid", sessionId); cmd.Parameters.AddWithValue("$name", sessionName); @@ -244,6 +263,7 @@ INSERT INTO session_history cmd.Parameters.AddWithValue("$args", args); cmd.Parameters.AddWithValue("$gid", groupId); cmd.Parameters.AddWithValue("$ts", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + cmd.Parameters.AddWithValue("$snap", (object?)snapshotJson ?? DBNull.Value); await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); } @@ -252,7 +272,7 @@ INSERT INTO session_history using var _dbLock = await DbGate.AcquireAsync().ConfigureAwait(false); await using var cmd = _db.CreateCommand(); cmd.CommandText = """ - SELECT session_id, session_name, working_folder, command, args, group_id, exited_at + SELECT session_id, session_name, working_folder, command, args, group_id, exited_at, snapshot_json FROM session_history WHERE session_id = $sid ORDER BY exited_at DESC LIMIT 1 """; cmd.Parameters.AddWithValue("$sid", sessionId); @@ -261,7 +281,8 @@ INSERT INTO session_history return new SessionHistoryEntry( r.GetString(0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetString(4), r.GetString(5), - DateTimeOffset.FromUnixTimeMilliseconds(r.GetInt64(6)).LocalDateTime); + DateTimeOffset.FromUnixTimeMilliseconds(r.GetInt64(6)).LocalDateTime, + r.IsDBNull(7) ? null : r.GetString(7)); } public async Task GetLatestSessionHistoryForFolderAsync(string folderPath) @@ -269,7 +290,7 @@ INSERT INTO session_history using var _dbLock = await DbGate.AcquireAsync().ConfigureAwait(false); await using var cmd = _db.CreateCommand(); cmd.CommandText = """ - SELECT session_id, session_name, working_folder, command, args, group_id, exited_at + SELECT session_id, session_name, working_folder, command, args, group_id, exited_at, snapshot_json FROM session_history WHERE working_folder = $fp ORDER BY exited_at DESC LIMIT 1 """; cmd.Parameters.AddWithValue("$fp", folderPath); @@ -278,7 +299,8 @@ INSERT INTO session_history return new SessionHistoryEntry( r.GetString(0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetString(4), r.GetString(5), - DateTimeOffset.FromUnixTimeMilliseconds(r.GetInt64(6)).LocalDateTime); + DateTimeOffset.FromUnixTimeMilliseconds(r.GetInt64(6)).LocalDateTime, + r.IsDBNull(7) ? null : r.GetString(7)); } public async Task DeleteSessionLogsAsync(string sessionId) diff --git a/src/CodeShellManager/Services/SessionConfigEditor.cs b/src/CodeShellManager/Services/SessionConfigEditor.cs index de6f7a8..feaa49f 100644 --- a/src/CodeShellManager/Services/SessionConfigEditor.cs +++ b/src/CodeShellManager/Services/SessionConfigEditor.cs @@ -12,7 +12,7 @@ namespace CodeShellManager.Services; /// True when the change can only take effect by tearing down and restarting the PTY — /// see for the exact rules. /// -/// True when the local working folder moved (git info must be re-resolved). +/// True when the local folder or WSL distro/Linux folder moved (git info must be re-resolved). /// True when any per-session appearance override differs. public readonly record struct SessionConfigChange( bool AnyChange, @@ -29,19 +29,26 @@ public static class SessionConfigEditor { public static SessionConfigChange Diff(ShellSession s, SessionConfigDraft d) { - bool modeChanged = d.IsRemote != s.IsRemote; + bool modeChanged = d.Kind != s.Kind; - // Only meaningful while the session stays local — a mode flip already forces a - // relaunch, and stale ssh/folder leftovers from the other mode shouldn't count. - bool folderChanged = !d.IsRemote && !s.IsRemote + // Kind-specific fields only count while the kind is unchanged — a kind flip already + // forces a relaunch, and leftovers from a previous kind must not read as edits. + bool sameKind = !modeChanged; + bool folderChanged = sameKind && s.Kind == SessionKind.Local && !PathsEqual(d.WorkingFolder, s.WorkingFolder); - bool sshChanged = d.IsRemote && s.IsRemote + bool sshChanged = sameKind && s.Kind == SessionKind.Ssh && (!Eq(d.SshUser, s.SshUser) || !Eq(d.SshHost, s.SshHost) || d.SshPort != s.SshPort || !Eq(d.SshRemoteFolder, s.SshRemoteFolder)); + bool wslFolderChanged = sameKind && s.Kind == SessionKind.Wsl + && (!Eq(d.WslDistro, s.WslDistro) + || !LinuxPathsEqual(d.WslWorkingFolder, s.WslWorkingFolder)); + bool wslChanged = wslFolderChanged + || (sameKind && s.Kind == SessionKind.Wsl && !Eq(d.WslUser, s.WslUser)); + bool launchChanged = !Eq(d.Command, s.Command) || !Eq(d.Args, s.Args); bool appearanceChanged = @@ -73,13 +80,13 @@ public static SessionConfigChange Diff(ShellSession s, SessionConfigDraft d) || Cleared(d.ProfileRetroEffect, s.ProfileRetroEffect) || Cleared(d.ProfileColorSchemeJson, s.ProfileColorSchemeJson); - bool anyChange = modeChanged || folderChanged || sshChanged || launchChanged + bool anyChange = modeChanged || folderChanged || sshChanged || wslChanged || launchChanged || appearanceChanged || !Eq(d.Name, s.Name); - bool requiresRelaunch = modeChanged || folderChanged || sshChanged || launchChanged + bool requiresRelaunch = modeChanged || folderChanged || sshChanged || wslChanged || launchChanged || transparencyChanged || overridesCleared; - return new SessionConfigChange(anyChange, requiresRelaunch, folderChanged, appearanceChanged); + return new SessionConfigChange(anyChange, requiresRelaunch, folderChanged || wslFolderChanged, appearanceChanged); } /// @@ -92,12 +99,26 @@ public static void Apply(ShellSession s, SessionConfigDraft d) s.Name = d.Name; s.Command = d.Command; s.Args = d.Args; - s.IsRemote = d.IsRemote; - s.WorkingFolder = d.WorkingFolder; + s.Kind = d.Kind; s.SshUser = d.SshUser; s.SshHost = d.SshHost; s.SshPort = d.SshPort; s.SshRemoteFolder = d.SshRemoteFolder; + s.WslDistro = d.WslDistro.Trim(); + s.WslUser = d.WslUser; + s.WslWorkingFolder = d.WslWorkingFolder.Trim(); + // WSL sessions keep WorkingFolder as the \\wsl$ UNC mirror of the Linux path so + // Explorer, git polling and the sidebar need no special-casing (see CLAUDE.md + // "WSL Sessions"). Derive it here so the two can never drift apart — shared with + // every other path that creates/edits a WSL session (ResyncWslWorkingFolder). + if (d.Kind == SessionKind.Wsl) + { + WslDiscoveryService.ResyncWslWorkingFolder(s); + } + else + { + s.WorkingFolder = d.WorkingFolder; + } s.ProfileFontFamily = d.ProfileFontFamily; s.ProfileFontSize = d.ProfileFontSize; @@ -118,6 +139,10 @@ private static bool Eq(string? a, string? b) => private static bool Cleared(string? now, string? before) => string.IsNullOrEmpty(now) && !string.IsNullOrEmpty(before); + /// Linux path compare: exact, trailing-slash tolerant, case-sensitive (ext4 is). + internal static bool LinuxPathsEqual(string a, string b) => + string.Equals((a ?? "").Trim().TrimEnd('/'), (b ?? "").Trim().TrimEnd('/'), StringComparison.Ordinal); + /// Case-insensitive path compare that tolerates trailing slashes and bad input. internal static bool PathsEqual(string a, string b) { diff --git a/src/CodeShellManager/Services/StateService.cs b/src/CodeShellManager/Services/StateService.cs index a8ebfab..0041f0a 100644 --- a/src/CodeShellManager/Services/StateService.cs +++ b/src/CodeShellManager/Services/StateService.cs @@ -96,6 +96,10 @@ internal static AppState Normalize(AppState s) s.RecentlyClosed ??= []; s.GroupLayouts ??= new(); s.Settings ??= new(); + // Legacy-field migration lives here, in the loader, so the models stay free of + // deserialisation-order tricks. Import goes through this too (ImportExportService). + foreach (var session in s.Sessions) session.MigrateLegacyFields(); + foreach (var entry in s.RecentlyClosed) entry.MigrateLegacyFields(); return s; } diff --git a/src/CodeShellManager/Services/WslDiscoveryService.cs b/src/CodeShellManager/Services/WslDiscoveryService.cs new file mode 100644 index 0000000..0e219f5 --- /dev/null +++ b/src/CodeShellManager/Services/WslDiscoveryService.cs @@ -0,0 +1,311 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CodeShellManager.Models; + +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 + .Where(d => !IsDockerInternalDistro(d.Name)) + .OrderByDescending(d => d.IsDefault) + .ThenBy(d => d.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// + /// True for Docker Desktop's own internal distros ("docker-desktop" and, on older + /// versions, "docker-desktop-data") — BusyBox-based plumbing that Docker rebuilds on + /// its own updates, not a user environment. They have no bash (and are root-only), so + /// offering them in the picker just hands the user a confusing "bash: not found" the + /// first time they try to use one — which is exactly what happened during manual + /// testing here. Exact, case-insensitive match only: a user-imported distro that merely + /// *contains* the phrase (e.g. "my-docker-desktop-clone") must still be offered. + /// + private static bool IsDockerInternalDistro(string name) => + string.Equals(name, "docker-desktop", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "docker-desktop-data", StringComparison.OrdinalIgnoreCase); + + /// + /// Resolves the home directory inside a WSL distro for the given user (or the distro's + /// default user when is null/empty). Cached per (distro, user) — + /// 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(); + + /// + /// Resolves the login shell to use inside a WSL distro — "bash" when it's present, + /// "sh" otherwise (minimal distros like Alpine/BusyBox images or Docker Desktop's own + /// "docker-desktop" distro have no bash, so hardcoding it fails every session and run + /// command there). Cached per (distro, user) exactly like . + /// Never throws; any failure (WSL not running, timeout, non-zero exit) returns "bash" — + /// that preserves today's behaviour rather than silently downgrading a distro that + /// actually works. + /// + public static async Task GetLoginShellAsync(string distro, string? user = null) + { + if (string.IsNullOrWhiteSpace(distro)) return "bash"; + string normalizedUser = user?.Trim() ?? ""; + string key = $"{distro}|{normalizedUser}"; + lock (_shellCache) + { + if (_shellCache.TryGetValue(key, out var cached)) return cached; + } + + try + { + string args = $"-d {Models.ShellSession.QuoteForCmd(distro)}"; + if (!string.IsNullOrEmpty(normalizedUser)) + args += $" -u {Models.ShellSession.QuoteForCmd(normalizedUser)}"; + // -e (not --) for the same reason BuildWslArgs uses it — see ShellSession. + args += " -e sh -c \"command -v bash >/dev/null 2>&1 && echo bash || echo sh\""; + + var psi = new ProcessStartInfo("wsl.exe") + { + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + using var process = Process.Start(psi); + if (process is null) return "bash"; + + // Drain both streams — see GetDistroHomeAsync for why stderr must be read too. + var outTask = process.StandardOutput.ReadToEndAsync(); + var errTask = process.StandardError.ReadToEndAsync(); + var bothTask = Task.WhenAll(outTask, errTask); + var completed = await Task.WhenAny(bothTask, Task.Delay(3000)); + if (completed != bothTask) { try { process.Kill(); } catch { } return "bash"; } + try { await process.WaitForExitAsync(); } catch { } + if (process.ExitCode != 0) return "bash"; + + string result = outTask.Result.Trim(); + string shell = result == "sh" ? "sh" : "bash"; + lock (_shellCache) _shellCache[key] = shell; + return shell; + } + catch (Exception) { return "bash"; } + } + + private static readonly Dictionary _shellCache = new(); + + /// + /// Converts a WSL distro + Linux-style path to the Windows UNC view of that path + /// (\\wsl$\Ubuntu\home\alice). Used by GitService and PseudoTerminal's + /// 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}"; + } + + /// + /// Re-derives from + /// + when + /// the session is WSL — the "UNC mirror invariant" (see CLAUDE.md "WSL Sessions"). A no-op + /// for Local/Ssh sessions. One shared helper so every path that creates or edits a WSL + /// session — dialog creation, duplicate/worktree, session-config edit, reopen-from-history — + /// derives the same way instead of trusting a value + /// that may have been hand-edited or copied from a stale source (e.g. state.json, + /// RecentlyClosed, an import file). + /// + public static void ResyncWslWorkingFolder(ShellSession session) + { + if (session.Kind != SessionKind.Wsl) return; + session.WorkingFolder = ToUncPath((session.WslDistro ?? "").Trim(), session.WslWorkingFolder); + } + + /// + /// Splits a WSL UNC (\\wsl$\Ubuntu\home\alice or \\wsl.localhost\…, either + /// slash direction) into (distro, linuxPath). linuxPath is "/" for the distro root. + /// Returns (null, "") for anything that isn't a WSL UNC. The single parser for the + /// whole app — GitService and NewSessionDialog both delegate here. + /// + public static (string? distro, string linuxPath) TryParseUncPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) return (null, ""); + string normalized = path.Replace('/', '\\').TrimEnd('\\'); + foreach (var prefix in new[] { @"\\wsl$\", @"\\wsl.localhost\" }) + { + if (!normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; + string rest = normalized[prefix.Length..]; + if (string.IsNullOrEmpty(rest)) return (null, ""); + int slash = rest.IndexOf('\\'); + string distro = slash < 0 ? rest : rest[..slash]; + if (string.IsNullOrEmpty(distro)) return (null, ""); + string linuxRest = slash < 0 ? "" : rest[(slash + 1)..]; + return (distro, string.IsNullOrEmpty(linuxRest) ? "/" : "/" + linuxRest.Replace('\\', '/')); + } + return (null, ""); + } +} diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index 6bc2bc0..14ca23f 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; @@ -88,10 +77,37 @@ public SessionViewModel(ShellSession session) _ = PollGitInfoAsync(_gitPollCts.Token); } + /// + /// Git poll cadence per kind. WSL probes spawn wsl.exe (much heavier than a local git + /// spawn) and defeat WSL2's idle-VM shutdown, so they run a third as often. + /// + internal static TimeSpan GitPollIntervalFor(SessionKind kind) => + kind == SessionKind.Wsl ? TimeSpan.FromSeconds(30) : TimeSpan.FromSeconds(10); + + // WSL only: a "not a repo" answer costs a wsl.exe spawn per tick, so remember it. + // Local folders keep re-probing (a `git init` should be picked up within a tick). + private bool _repoRootProbedNegative; + public async Task RefreshGitInfoAsync() { - if (Session.IsRemote || _gitOverriddenByOsc) return; - var (branch, isDirty) = await GitService.GetGitInfoAsync(Session.WorkingFolder); + // SSH sessions have no local working folder to inspect. WSL sessions store + // their WorkingFolder as a `\\wsl$\\...` UNC; GitService detects that + // and dispatches to `wsl.exe -- git -C ` internally (Git for + // Windows itself trips on those UNCs — dubious-ownership / .git symlinks). + if (Session.Kind == SessionKind.Ssh || _gitOverriddenByOsc) return; + + // Captured before the probe goes off-thread, not read from _gitPollCts afterwards: + // Dispose() cancels (then disposes) that CTS if the session closes while the + // Task.Run below is still in flight, and CancellationToken.IsCancellationRequested + // never throws even once the source is disposed, so this stays safe either way. + var token = _gitPollCts.Token; + + // Off the dispatcher: GitService begins with a synchronous Directory.Exists, and on + // a \\wsl$ share that boots a stopped distro (seconds). Continuations return to the + // captured UI context, so the property sets below stay on the UI thread. + string folder = Session.WorkingFolder; + var (branch, isDirty) = await Task.Run(() => GitService.GetGitInfoAsync(folder)); + if (token.IsCancellationRequested) return; // session closed while the probe was off-thread GitBranch = branch; GitIsDirty = isDirty; GitInfoLoaded = true; @@ -99,8 +115,13 @@ public async Task RefreshGitInfoAsync() // RepoRoot is stable for the life of the session — resolve it once. Don't gate on // a non-empty branch: detached HEADs report no branch but are still valid repos // that should participate in sibling detection, shared accent color, and clusters. - if (RepoRoot == null) - RepoRoot = await GitService.GetRepoRootAsync(Session.WorkingFolder); + if (RepoRoot == null && !_repoRootProbedNegative) + { + string? repoRoot = await Task.Run(() => GitService.GetRepoRootAsync(folder)); + if (token.IsCancellationRequested) return; // session closed while the probe was off-thread + RepoRoot = repoRoot; + if (RepoRoot == null && Session.Kind == SessionKind.Wsl) _repoRootProbedNegative = true; + } } /// Short repo + branch label shown beneath the session name when sibling worktrees are open. @@ -117,7 +138,7 @@ public string WorktreeSubtitle private async Task PollGitInfoAsync(CancellationToken ct) { - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10)); + using var timer = new PeriodicTimer(GitPollIntervalFor(Session.Kind)); try { while (await timer.WaitForNextTickAsync(ct)) @@ -231,6 +252,8 @@ public Task ReloadGitInfoAsync() // pushed git info via OSC 9001 was describing the old one. Let the local poller // back in until a program re-declares itself. _gitOverriddenByOsc = false; + // Same reason: a "not a repo" answer for the old folder doesn't apply to the new one. + _repoRootProbedNegative = false; return RefreshGitInfoAsync(); } diff --git a/src/CodeShellManager/Views/NewSessionDialog.xaml b/src/CodeShellManager/Views/NewSessionDialog.xaml index 6cbd49a..d956198 100644 --- a/src/CodeShellManager/Views/NewSessionDialog.xaml +++ b/src/CodeShellManager/Views/NewSessionDialog.xaml @@ -1,7 +1,7 @@ @@ -143,7 +143,23 @@ - + + + +