From 91a68d6c38ed3b15d4a3020f1a183665257a8c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Aslo-=C3=98stergaard?= Date: Fri, 6 Nov 2026 11:38:21 +0100 Subject: [PATCH 1/2] fix(explorer): open the right folder for forward-slash and comma paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Open in Explorer" ran explorer.exe with the folder as raw command-line text, and Explorer silently opened Documents instead for two kinds of path — measured by asking the Shell which folder each new Explorer window showed: - forward slashes (C:/repos/wt-feat): the form git prints, and so the form a session opened through "New session in sibling worktree" — and now the Worktrees menu — stored as its WorkingFolder; - a comma in the name: Explorer splits its arguments on commas, and quoting the path does not help (also measured). Services/FolderOpener opens the folder itself through the shell (ShellExecute "open" on the full Windows path), which opened the right folder for every case tried: plain, spaces, comma, &, %, forward slashes, trailing separator, 8.3. It refuses anything that is not an existing directory first — ShellExecute on a FILE runs it, and a session's folder comes from state.json and imports — and runs off the UI thread (the check is \wsl$ I/O for WSL). Used by the toolbar 📁, the sidebar menu and note search results. LaunchSessionInSiblingWorktreeAsync now stores the Windows form of the git path, so new worktree sessions don't carry forward slashes at all. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX --- src/CodeShellManager/MainWindow.xaml.cs | 8 +- src/CodeShellManager/Services/FolderOpener.cs | 73 +++++++++++++++++ .../ViewModels/SessionViewModel.cs | 11 +-- .../FolderOpenerTests.cs | 78 +++++++++++++++++++ 4 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 src/CodeShellManager/Services/FolderOpener.cs create mode 100644 tests/CodeShellManager.Tests/FolderOpenerTests.cs diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 6bbc0fd..4ce592c 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -1100,6 +1100,11 @@ private static void InheritSessionKindFrom(Models.ShellSession target, Models.Sh /// private async Task LaunchSessionInSiblingWorktreeAsync(SessionViewModel parent, string worktreePath) { + // The path comes from `git worktree list`, which prints forward slashes. Stored as-is it + // became the session's WorkingFolder, and "Open in Explorer" then opened Documents — + // so store the Windows form. (WSL worktrees arrive as \\wsl$ UNC paths; this keeps + // them UNC, only fixing the separators.) + worktreePath = Services.FolderOpener.WindowsPath(worktreePath); if (!System.IO.Directory.Exists(worktreePath)) { MessageBox.Show(this, $"Worktree folder '{worktreePath}' does not exist.", @@ -5751,8 +5756,7 @@ private async void SearchResult_Click(object sender, RoutedEventArgs e) // Note results: open folder in Explorer if (result.IsNote) { - if (!string.IsNullOrEmpty(result.FolderPath) && Directory.Exists(result.FolderPath)) - System.Diagnostics.Process.Start("explorer.exe", result.FolderPath); + _ = Services.FolderOpener.OpenAsync(result.FolderPath); return; } diff --git a/src/CodeShellManager/Services/FolderOpener.cs b/src/CodeShellManager/Services/FolderOpener.cs new file mode 100644 index 0000000..b0e2ee0 --- /dev/null +++ b/src/CodeShellManager/Services/FolderOpener.cs @@ -0,0 +1,73 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; + +namespace CodeShellManager.Services; + +/// +/// Opens a folder in the user's file manager, reliably. See "Opening a folder in Explorer" +/// in CLAUDE.md. +/// +/// +/// It used to be Process.Start("explorer.exe", folder) — the folder as raw command-line +/// text — and Explorer silently opened Documents instead of the folder for two kinds of +/// path (measured, by asking the Shell which folder each new Explorer window showed): +/// +/// forward slashes (C:/repos/wt-feat) — the form git prints, and so the form a +/// session opened through "New session in sibling worktree" stored; +/// a comma anywhere in the name — Explorer splits its arguments on commas, and quoting +/// the path does not stop it. +/// +/// Opening the folder itself through the shell (ShellExecute "open" on the full Windows path) +/// opened the right folder for every case tried: plain, spaces, comma, &, %, +/// forward slashes, trailing separator, 8.3 short name. It also honours a replacement file +/// manager the user has set as the folder handler. +/// +public static class FolderOpener +{ + /// + /// The path to hand to the shell, or null when it must not be opened: not rooted, or not + /// an existing DIRECTORY. The second is a security check, not tidiness — ShellExecute on a + /// file runs it, and a session's folder comes from state.json and imports, which are + /// untrusted. Touches the disk (and \\wsl$), so call it off the UI thread. + /// + public static string? Target(string? folder) + { + if (string.IsNullOrWhiteSpace(folder)) return null; + try + { + if (!Path.IsPathFullyQualified(folder)) return null; + string full = WindowsPath(folder); + return Directory.Exists(full) ? full : null; + } + catch { return null; } + } + + /// + /// Backslashes, absolute, no trailing separator (except a drive root). The form every + /// Windows tool accepts — use it for any folder that came from git. + /// + public static string WindowsPath(string folder) + { + string full = Path.GetFullPath(folder); + string trimmed = Path.TrimEndingDirectorySeparator(full); + return trimmed.EndsWith(':') ? trimmed + Path.DirectorySeparatorChar : trimmed; + } + + /// + /// Opens off the calling thread. Returns false when it was not + /// an existing folder or the shell refused; never throws. + /// + public static Task OpenAsync(string? folder) => Task.Run(() => + { + string? target = Target(folder); + if (target == null) return false; + try + { + Process.Start(new ProcessStartInfo(target) { UseShellExecute = true, Verb = "open" })?.Dispose(); + return true; + } + catch { return false; } + }); +} diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index 910fb97..dbfc3de 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -329,12 +329,13 @@ private async Task PollGitInfoAsync(CancellationToken ct) [RelayCommand] private void Close() => CloseRequested?.Invoke(this); + /// + /// Opens the folder via — not explorer.exe <folder>, + /// which silently opened Documents for forward-slash paths and names with a comma. Off the + /// UI thread: the existence check is \\wsl$ I/O for a WSL session. + /// [RelayCommand] - private void OpenInExplorer() - { - if (System.IO.Directory.Exists(Session.WorkingFolder)) - System.Diagnostics.Process.Start("explorer.exe", Session.WorkingFolder); - } + private void OpenInExplorer() => _ = FolderOpener.OpenAsync(Session.WorkingFolder); /// /// Applies a CSM shell-integration payload (OSC 9001) emitted by the running program. diff --git a/tests/CodeShellManager.Tests/FolderOpenerTests.cs b/tests/CodeShellManager.Tests/FolderOpenerTests.cs new file mode 100644 index 0000000..91fbee4 --- /dev/null +++ b/tests/CodeShellManager.Tests/FolderOpenerTests.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// "Open in Explorer" opened Documents instead of the folder for forward-slash paths and names +/// with a comma. The fix opens the folder through the shell, which only must never happen for +/// something that isn't an existing directory. See "Opening a folder in Explorer" in CLAUDE.md. +/// +public sealed class FolderOpenerTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "csm-folderopener-" + Guid.NewGuid().ToString("N")[..8]); + + public FolderOpenerTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + [Fact] + public void A_forward_slash_path_from_git_becomes_the_windows_form() + { + string dir = Directory.CreateDirectory(Path.Combine(_root, "wt-feat")).FullName; + string asGitPrintsIt = dir.Replace('\\', '/'); + + Assert.Equal(Path.GetFullPath(dir), FolderOpener.Target(asGitPrintsIt)); + Assert.DoesNotContain('/', FolderOpener.Target(asGitPrintsIt)!); + } + + [Theory] + [InlineData("a,b")] // Explorer splits its arguments on commas, even quoted + [InlineData("x & y")] + [InlineData("100%")] + [InlineData("with space")] + public void Awkward_folder_names_are_accepted_unchanged(string name) + { + string dir = Directory.CreateDirectory(Path.Combine(_root, name)).FullName; + Assert.Equal(dir, FolderOpener.Target(dir)); + Assert.Equal(dir, FolderOpener.Target(dir + "\\")); // trailing separator dropped + } + + [Fact] + public void A_drive_root_keeps_its_separator() + { + string root = Path.GetPathRoot(_root)!; // "C:\" + Assert.Equal(root, FolderOpener.Target(root.TrimEnd('\\') + "/")); + } + + [Fact] + public void Nothing_but_an_existing_directory_is_ever_opened() + { + // ShellExecute on a FILE runs it, and a session's folder comes from state.json and + // imports — untrusted. So a file, a missing path, a relative path or a URL must all be + // refused before the shell sees them. + string file = Path.Combine(_root, "payload.cmd"); + File.WriteAllText(file, "@echo pwned"); + + Assert.Null(FolderOpener.Target(file)); + Assert.Null(FolderOpener.Target(Path.Combine(_root, "missing"))); + Assert.Null(FolderOpener.Target("relative\\folder")); + Assert.Null(FolderOpener.Target("https://example.com/")); + Assert.Null(FolderOpener.Target("calc.exe")); + Assert.Null(FolderOpener.Target("")); + Assert.Null(FolderOpener.Target(null)); + } + + [Fact] + public async Task OpenAsync_refuses_a_file_without_throwing() + { + string file = Path.Combine(_root, "payload.cmd"); + File.WriteAllText(file, "@echo pwned"); + Assert.False(await FolderOpener.OpenAsync(file)); + } +} From 318254b5828abaa0f471fbdb729ff6268c43eaf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Aslo-=C3=98stergaard?= Date: Fri, 6 Nov 2026 11:48:47 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20pre-release=20review=20=E2=80=94=20n?= =?UTF-8?q?ever=20delete=20a=20session=20on=20a=20failed=20relaunch;=20wor?= =?UTF-8?q?ktree=20remove=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From two read-only reviews of everything since v0.9.0. Each finding was reproduced before fixing. Relaunch (restore, wake) — data loss since before v0.9.0: - Both called LaunchSessionAsync with the default removeOnFailure: true, so a session whose launch was refused or whose PTY failed to start (WSL distro uninstalled, claude briefly off PATH) was DELETED, and the next save made it permanent. Wake's catch never ran: the PTY-failure path returns, not throws. Both now pass removeOnFailure: false and end in FallBackToDormantIfNotLive, which also covers every WebView2 failure on restore (#175 only covered the two batched ones). Verified before/after with a real state.json via CSM_STATE_PATH: old build — session gone from sidebar and file; fixed — kept, asleep. Worktree remove (#180): - Refused when another worktree lives inside the target. git's clean check skips ignored folders: with B in A's .gitignored .worktrees/ holding uncommitted work, `git worktree remove A` exited 0 and deleted B (reproduced with git 2.54). Checked against a list re-read at click time. - Sleeping sessions are deleted only after git removed the folder — they never go through Recently closed, so a refusal used to lose them for good. - WSL: \wsl.localhost folded into \wsl$ (otherwise no match at all), and below the distro paths compare case-sensitively (/home/u/WT is not .../wt). Not fixed here (follow-up issue): window bounds restore under per-monitor DPI on mixed-DPI setups, the busy-slot key before RepoRoot resolves, and ParseStatusV2 showing every unmerged entry as "UU". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX --- CLAUDE.md | 34 +++++++++- src/CodeShellManager/MainWindow.Git.cs | 43 +++++++++---- src/CodeShellManager/MainWindow.xaml.cs | 47 ++++++++------ src/CodeShellManager/Services/GitWorktrees.cs | 63 ++++++++++++++++--- .../GitWorktreeTests.cs | 46 ++++++++++++++ 5 files changed, 194 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 555e68e..2cd4041 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -443,7 +443,7 @@ warning is suppressed in the csproj deliberately — it recommends WinForms' 5. Termination paths: - **Close** (`vm.CloseCommand`) → `MainViewModel.OnSessionCloseRequested` → `vm.Dispose()` + remove from `Sessions` + `SessionManager.RemoveSession()`. Session is gone from `state.json`. - **Sleep** (`SleepSession(vm)`) → `vm.Dispose()` + remove from `Sessions` but **keep** the `ShellSession` in `SessionManager` with `IsDormant = true`. A muted dormant sidebar entry replaces the active one. - - **Wake** (`WakeSessionAsync(session)`) → re-runs `LaunchSessionAsync(session, restoring: true)` — same path as restore-on-startup. + - **Wake** (`WakeSessionAsync(session)`) → re-runs `LaunchSessionAsync(session, restoring: true, removeOnFailure: false)` — same path as restore-on-startup. **Both pass `removeOnFailure: false` and then `FallBackToDormantIfNotLive`.** With the default, a session whose launch was refused or whose PTY failed to start (a WSL distro uninstalled, `claude` briefly off PATH) was *deleted* from `SessionManager` and the next save made it permanent — the PTY-failure path returns rather than throws, so Wake's catch never ran. Verified before/after against a real `state.json` (via `CSM_STATE_PATH`): the old build lost the session from the sidebar and the file; the fixed one keeps it asleep. Every relaunch path — restore, wake, restart — now ends a failure with the session asleep and editable. - **Restart** (`RestartSessionAsync(vm)`) → sleep-style teardown *without* the dormant bookkeeping, then `LaunchSessionAsync(session, restoring: true, removeOnFailure: false)`. The `ShellSession` stays in `SessionManager` (so Id, group, run commands and sidebar slot survive) and never enters the recently-closed ring. Reachable from the **↻ toolbar button and the "Restart" menu entries** (see "Restarting sessions" below) and from "Edit session…". Both non-default arguments matter: `restoring: true` makes a Claude session resume rather than start a fresh conversation (matching Wake — a restart tears down the same way, so it must recover the same way), and `removeOnFailure: false` stops a bad edit from *deleting* the session, since `LaunchSessionAsync`'s PTY-failure path calls `SessionManager.RemoveSession` — correct for a session that never started, destructive for a relaunch. If the relaunch fails either way, the session falls back to dormant so the row stays visible and fixable instead of leaving a launching placeholder that never resolves. A Claude restart also **waits for the old process to actually exit** (`DisposeAndWaitForExitAsync` + a config quiesce) before relaunching. Without that it recreates the concurrent-config-writer race the launch stagger and the shutdown loop both exist to prevent, and `--resume` can read a session index the outgoing process hasn't finalised. Non-Claude sessions skip the wait — they don't touch that file. @@ -836,6 +836,19 @@ git itself refuses modified, untracked or locked worktrees and never removes the Deleting the branch is a separate, explicit question afterwards, using `branch -d` (not `-D`), so an unmerged branch is kept. +**Refused when another worktree lives inside it.** git's clean check does not look inside an +ignored folder, and a nested worktree usually sits in one (`.worktrees/` in `.gitignore`). +Reproduced with git 2.54: worktree B inside A's ignored `.worktrees/`, with uncommitted work — +A reports clean, `git worktree remove A` exits 0 and deletes B, work included. +`GitWorktrees.NestedInside` is checked against a list re-read at click time, not the menu's. + +**Sleeping sessions are deleted only after git has removed the folder.** They never go through +Recently closed, so deleting them before git ran meant a refusal lost them for good. + +**WSL paths follow Linux case rules.** `\\wsl.localhost\` is folded into `\\wsl$\` (GitService +always reports the latter), and below the distro name paths compare case-sensitively — +`/home/u/WT` is not `/home/u/wt`, and matching it would close the wrong worktree's sessions. + **Close the sessions and wait for their processes to exit first.** On Windows a process whose cwd is inside the folder locks it, and git then fails half-way through the delete with `failed to delete … Permission denied` — verified by running the remove from inside the @@ -857,6 +870,25 @@ fails 2. The full flow was also run end to end in the app with a terminal open i worktree: session closed, folder deleted, merged branch deleted. `GitServiceThreadingTests` now also find the repo root from a worktree checkout, where `.git` is a file. +## Opening a folder in Explorer + +"Open in Explorer" (toolbar 📁, sidebar menu, note search results) goes through +`Services/FolderOpener`, never `explorer.exe `. Passing the folder as Explorer's command +line silently opened **Documents** instead for two kinds of path, measured by asking the Shell +which folder each new Explorer window showed: + +- **forward slashes** (`C:/repos/wt-feat`) — the form git prints, which "New session in sibling + worktree" (and the Worktrees menu) stored as a session's `WorkingFolder`; +- **a comma** anywhere in the name — Explorer splits its arguments on commas, and quoting does + not stop it. + +`FolderOpener` opens the folder itself through the shell (ShellExecute `open` on the full Windows +path), which was right for every case tried — spaces, comma, `&`, `%`, forward slashes, trailing +separator, 8.3 — and honours a replacement file manager. **It refuses anything that is not an +existing directory first**: ShellExecute on a *file* runs it, and a folder path comes from +`state.json` and imports. `LaunchSessionInSiblingWorktreeAsync` now stores the Windows form of +the git path, so new worktree sessions don't carry forward slashes at all. + ## Per-Session Notes Each session gets a collapsible 📝 notepad panel between the terminal toolbar and the terminal. Toggled by the 📝 button on the terminal toolbar; the panel is a docked 160px-high `TextBox` (`Visibility.Collapsed` by default). diff --git a/src/CodeShellManager/MainWindow.Git.cs b/src/CodeShellManager/MainWindow.Git.cs index bff16ad..ca634a9 100644 --- a/src/CodeShellManager/MainWindow.Git.cs +++ b/src/CodeShellManager/MainWindow.Git.cs @@ -422,8 +422,7 @@ private MenuItem BuildWorktreesMenu(SessionViewModel vm, bool busy) foreach (var w in worktrees) { if (w.IsBare) continue; - bool isCurrent = current != null && GitWorktrees.Normalize(w.Path) - .Equals(GitWorktrees.Normalize(current.Path), StringComparison.OrdinalIgnoreCase); + bool isCurrent = current != null && GitWorktrees.SamePath(w.Path, current.Path); var mi = new MenuItem { Header = new TextBlock { Text = GitWorktrees.Label(w, statusOf.GetValueOrDefault(w.Path)) }, @@ -543,6 +542,22 @@ private async Task RemoveWorktreeAsync( SessionViewModel vm, GitService.WorktreeInfo wt, string mainPath, GitRepoStatus? status) { string name = GitWorktrees.Name(wt.Path); + + // Re-read the list now rather than trusting the menu's: a worktree created inside this + // one since the menu opened must still block the remove (see NestedInside). + var (fresh, _) = await LoadWorktreesAsync(wt.Path); + var nested = GitWorktrees.NestedInside(fresh, wt); + if (nested.Count > 0) + { + MessageBox.Show(this, + $"'{name}' contains another worktree:\n\n" + + string.Join("\n", nested.Select(n => $" • {GitWorktrees.Name(n.Path)} ({n.Path})")) + + "\n\nRemoving it would delete that worktree too, uncommitted work included — git does " + + "not check inside ignored folders. Remove the inner worktree first.", + "Remove worktree", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + if (status is { IsDirty: true }) { MessageBox.Show(this, @@ -596,16 +611,6 @@ private async Task RemoveWorktreeAsync( if (pty != null) exits.Add(WaitForPtyExitAsync(pty, timeoutMs: 10_000)); } await Task.WhenAll(exits); - foreach (var d in dormantHere) - { - _dormantSidebarItems.Remove(d.Id); - _sessionManager.RemoveSession(d.Id); - } - if (dormantHere.Count > 0) - { - RebuildSidebarOrder(); - _ = _vm.SaveStateAsync(); - } var r = await GitService.RemoveWorktreeAsync(mainPath, wt.Path); if (!r.Ok) @@ -618,6 +623,20 @@ private async Task RemoveWorktreeAsync( return; } + // Only now that git has removed the folder: deleted earlier, a refusal (a file + // created since the menu opened, an editor holding the folder) lost them for good — + // unlike live sessions they never go through Recently closed. + foreach (var d in dormantHere) + { + _dormantSidebarItems.Remove(d.Id); + _sessionManager.RemoveSession(d.Id); + } + if (dormantHere.Count > 0) + { + RebuildSidebarOrder(); + _ = _vm.SaveStateAsync(); + } + string removed = $"Removed worktree {name}."; if (!string.IsNullOrEmpty(wt.Branch) && MessageBox.Show(this, diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 4ce592c..b5a09fe 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -398,28 +398,24 @@ private async void OnLoaded(object sender, RoutedEventArgs e) long gateMs = restoreClock.ElapsedMilliseconds - gateStart; long launchStart = restoreClock.ElapsedMilliseconds; - try { await LaunchSessionAsync(s, restoring: true); } + // removeOnFailure: false — this is a relaunch of a saved session. With the + // default, a PTY that failed to start (claude briefly off PATH, a WSL distro + // uninstalled) made LaunchSessionAsync DELETE the session, and the next save + // made that permanent. Restart has always passed false for this reason. + try { await LaunchSessionAsync(s, restoring: true, removeOnFailure: false); } catch (Exception ex) { Log($"Restore FAILED for '{s.Name}': {ex}"); if (IsWebView2AccessDenied(ex) || Terminal.WebView2StartupError.IsIncompatibleCopy(ex)) - { webView2AccessDenied.Add(s.Name); - // The batched dialog below tells the user to reopen these from the - // sidebar, so make sure there is a row to reopen: put the session to - // sleep, as Wake and Restart already do when a launch fails. - if (!_sessionUi.ContainsKey(s.Id)) - { - _launchingSidebarItems.Remove(s.Id); - s.IsDormant = true; - AddDormantSidebarItem(s); - RebuildSidebarOrder(); - } - } else MessageBox.Show($"Failed to restore '{s.Name}': {ex.Message}", "Restore Error", MessageBoxButton.OK, MessageBoxImage.Warning); } + // Every failure shape — thrown, or handled inside LaunchSessionAsync — ends here + // with no pane. Put the session to sleep so it keeps a row that can be fixed + // and woken; otherwise it stays saved with no row anywhere. + FallBackToDormantIfNotLive(s); // Split the per-session cost into "waiting for the previous Claude" vs // "actually launching" (WebView2 create + navigate + PTY spawn). #82 asked @@ -5432,21 +5428,36 @@ private async Task WakeSessionAsync(ShellSession session) try { - await LaunchSessionAsync(session, restoring: true); + // removeOnFailure: false — see the restore loop. With the default, clicking a + // sleeping row whose program failed to start deleted the session for good: the + // PTY-failure path returns rather than throws, so the catch below never ran. + await LaunchSessionAsync(session, restoring: true, removeOnFailure: false); } catch (Exception ex) { Log($"Wake FAILED for '{session.Name}': {ex}"); - // Restore the dormant entry so the user doesn't lose access to the session - session.IsDormant = true; - AddDormantSidebarItem(session); - RebuildSidebarOrder(); MessageBox.Show($"Failed to wake '{session.Name}': {ex.Message}", "Wake Error", MessageBoxButton.OK, MessageBoxImage.Warning); } + // Thrown or handled inside, a failed wake leaves no pane: keep the sleeping row. + FallBackToDormantIfNotLive(session); _ = _vm.SaveStateAsync(); } + /// + /// After a relaunch (restore, wake): if no pane came up, put the session back to sleep so + /// it keeps a sidebar row the user can edit and wake. Restart does the same inline. + /// + private void FallBackToDormantIfNotLive(ShellSession session) + { + if (_sessionUi.ContainsKey(session.Id) || session.IsDormant) return; + if (!_sessionManager.Sessions.Any(s => s.Id == session.Id)) return; + _launchingSidebarItems.Remove(session.Id); + session.IsDormant = true; + AddDormantSidebarItem(session); + RebuildSidebarOrder(); + } + /// /// Builds and registers the muted sidebar row for a dormant session. The row is NOT /// added to the visual tree here: where it belongs — which group section, or whether it diff --git a/src/CodeShellManager/Services/GitWorktrees.cs b/src/CodeShellManager/Services/GitWorktrees.cs index 9bdbb5c..c67950a 100644 --- a/src/CodeShellManager/Services/GitWorktrees.cs +++ b/src/CodeShellManager/Services/GitWorktrees.cs @@ -14,9 +14,46 @@ namespace CodeShellManager.Services; /// public static class GitWorktrees { - /// Forward slashes, no trailing separator — the form git prints and compares. - public static string Normalize(string path) => - string.IsNullOrEmpty(path) ? "" : path.Replace('\\', '/').TrimEnd('/'); + /// + /// Forward slashes, no trailing separator — the form git prints and compares. The two + /// spellings of a WSL share (\\wsl.localhost\ and \\wsl$\) are folded to + /// one: GitService always reports \\wsl$, and a session opened as + /// \\wsl.localhost otherwise matched no worktree at all. + /// + public static string Normalize(string path) + { + if (string.IsNullOrEmpty(path)) return ""; + string n = path.Replace('\\', '/').TrimEnd('/'); + const string localhost = "//wsl.localhost/"; + if (n.StartsWith(localhost, StringComparison.OrdinalIgnoreCase)) n = "//wsl$/" + n[localhost.Length..]; + return n; + } + + /// + /// The comparable form of a path and how to compare it. Windows paths are + /// case-insensitive; inside a WSL distro they are NOT — /home/u/wt and + /// /home/u/WT are different folders, and treating them as one would close (and + /// delete the sleeping) sessions of the wrong worktree. The distro name itself is + /// case-insensitive, so only that segment is folded. + /// + private static (string key, StringComparison cmp) Key(string path) + { + string n = Normalize(path); + const string wsl = "//wsl$/"; + if (!n.StartsWith(wsl, StringComparison.OrdinalIgnoreCase)) return (n, StringComparison.OrdinalIgnoreCase); + int slash = n.IndexOf('/', wsl.Length); + string distro = slash < 0 ? n[wsl.Length..] : n[wsl.Length..slash]; + string rest = slash < 0 ? "" : n[slash..]; + return (wsl + distro.ToLowerInvariant() + rest, StringComparison.Ordinal); + } + + /// Same folder, by — case rules per Windows / WSL. + public static bool SamePath(string a, string b) + { + var (ka, cmp) = Key(a); + var (kb, _) = Key(b); + return ka.Length > 0 && ka.Equals(kb, cmp); + } /// /// The folder's real path, in d form: 8.3 short names expanded, @@ -67,10 +104,10 @@ private static extern SafeFileHandle CreateFileW(string name, uint access, uint /// public static bool IsInside(string folder, string root) { - string f = Normalize(folder), r = Normalize(root); + var (f, cmp) = Key(folder); + var (r, _) = Key(root); if (f.Length == 0 || r.Length == 0) return false; - return f.Equals(r, StringComparison.OrdinalIgnoreCase) - || f.StartsWith(r + "/", StringComparison.OrdinalIgnoreCase); + return f.Equals(r, cmp) || f.StartsWith(r + "/", cmp); } /// @@ -87,7 +124,7 @@ public static bool IsInside(string folder, string root) /// entry is "main" too but has no working tree, so it is never offered for removal. /// public static bool IsMain(IReadOnlyList worktrees, WorktreeInfo wt) => - worktrees.Count > 0 && Normalize(worktrees[0].Path).Equals(Normalize(wt.Path), StringComparison.OrdinalIgnoreCase); + worktrees.Count > 0 && SamePath(worktrees[0].Path, wt.Path); /// /// Branches checked out in a worktree OTHER than , mapped to that @@ -101,13 +138,23 @@ public static IReadOnlyDictionary BranchesElsewhere( foreach (var w in worktrees) { if (w.IsBare || string.IsNullOrEmpty(w.Branch)) continue; - if (current != null && Normalize(w.Path).Equals(Normalize(current.Path), StringComparison.OrdinalIgnoreCase)) + if (current != null && SamePath(w.Path, current.Path)) continue; map.TryAdd(w.Branch, w); } return map; } + /// + /// Other worktrees that live INSIDE 's folder. Removing the target + /// would delete them too: git's clean check does not look inside an ignored folder, and a + /// nested worktree usually sits in one (.worktrees/ in .gitignore). Reproduced with + /// git 2.54 — `worktree remove` on the outer one exited 0 and deleted the inner one along + /// with its uncommitted file. So the menu refuses while any are listed. + /// + public static IReadOnlyList NestedInside(IReadOnlyList worktrees, WorktreeInfo target) => + worktrees.Where(w => !w.IsBare && !SamePath(w.Path, target.Path) && IsInside(w.Path, target.Path)).ToList(); + /// The worktree's folder name — what the menus show. public static string Name(string path) { diff --git a/tests/CodeShellManager.Tests/GitWorktreeTests.cs b/tests/CodeShellManager.Tests/GitWorktreeTests.cs index 480bf40..dadce69 100644 --- a/tests/CodeShellManager.Tests/GitWorktreeTests.cs +++ b/tests/CodeShellManager.Tests/GitWorktreeTests.cs @@ -27,6 +27,30 @@ private static WorktreeInfo Wt(string path, string? branch = null, bool bare = f public void IsInside_is_a_path_test_not_a_string_prefix(string folder, string root, bool expected) => Assert.Equal(expected, GitWorktrees.IsInside(folder, root)); + [Theory] + // Inside a distro, Linux paths are case-sensitive: /home/u/WT is a different folder, and + // matching it would close (and delete the sleeping) sessions of the wrong worktree. + [InlineData(@"\\wsl$\Ubuntu\home\u\WT", @"\\wsl$\Ubuntu\home\u\wt", false)] + [InlineData(@"\\wsl$\Ubuntu\home\u\wt\src", @"\\wsl$\Ubuntu\home\u\wt", true)] + // …but the distro name is not, and the two share spellings are the same place. + [InlineData(@"\\wsl$\ubuntu\home\u\wt", @"\\wsl$\Ubuntu\home\u\wt", true)] + [InlineData(@"\\wsl.localhost\Ubuntu\home\u\wt", @"\\wsl$\Ubuntu\home\u\wt", true)] + [InlineData(@"\\WSL.LOCALHOST\Ubuntu\home\u\wt\x", @"\\wsl$\Ubuntu\home\u\wt", true)] + public void Wsl_paths_follow_linux_case_rules_and_both_share_spellings(string folder, string root, bool expected) + { + Assert.Equal(expected, GitWorktrees.IsInside(folder, root)); + Assert.Equal(expected && !folder.EndsWith(@"\src") && !folder.EndsWith(@"\x"), GitWorktrees.SamePath(folder, root)); + } + + [Fact] + public void NestedInside_finds_worktrees_living_in_the_target_folder_only() + { + var outer = Wt("C:/w/a", "a"); + var wts = new[] { Wt("C:/w/main", "main"), outer, Wt("C:/w/a/.worktrees/b", "b"), Wt("C:/w/a-sibling", "s") }; + Assert.Equal(new[] { "b" }, GitWorktrees.NestedInside(wts, outer).Select(w => w.Branch)); + Assert.Empty(GitWorktrees.NestedInside(wts, wts[2])); + } + [Fact] public void Containing_picks_the_deepest_worktree() { @@ -220,6 +244,28 @@ public void Linked_worktree_is_told_apart_from_the_main_checkout_and_a_submodule Assert.Null(GitWorktrees.LinkedWorktreeOf(fakeSub, Main)); } + [Fact] + public async Task A_worktree_nested_in_an_ignored_folder_is_detected_because_git_would_delete_it() + { + // The layout the pre-release review flagged, reproduced: worktree B inside A's + // .gitignored .worktrees/ folder, with uncommitted work. git reports A clean, and + // `worktree remove A` deletes B too. The menu must see B and refuse. + File.WriteAllText(Path.Combine(Main, ".gitignore"), ".worktrees/\n"); + Git(Main, "add", ".gitignore"); + Git(Main, "commit", "-q", "-m", "ignore"); + AddWorktree("A", "a"); + Git(WtPath("A"), "worktree", "add", "-q", "-b", "b", "--", Path.Combine(WtPath("A"), ".worktrees", "b")); + File.WriteAllText(Path.Combine(WtPath("A"), ".worktrees", "b", "work.txt"), "uncommitted\n"); + + var status = await GitService.GetStatusAsync(WtPath("A")); + Assert.False(status!.IsDirty); // git really does call A clean + + var wts = GitWorktrees.Canonicalize(await GitService.ListWorktreesAsync(Main)); + var a = GitWorktrees.Containing(wts, GitWorktrees.Canonical(WtPath("A")))!; + Assert.Equal("a", a.Branch); + Assert.Equal(new[] { "b" }, GitWorktrees.NestedInside(wts, a).Select(w => w.Branch)); + } + [Fact] public async Task Removing_a_clean_worktree_deletes_it_and_keeps_its_branch() {