Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 <folder>`. 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).
Expand Down
43 changes: 31 additions & 12 deletions src/CodeShellManager/MainWindow.Git.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) },
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
55 changes: 35 additions & 20 deletions src/CodeShellManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1100,6 +1096,11 @@ private static void InheritSessionKindFrom(Models.ShellSession target, Models.Sh
/// </summary>
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.",
Expand Down Expand Up @@ -5427,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();
}

/// <summary>
/// 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.
/// </summary>
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();
}

/// <summary>
/// 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
Expand Down Expand Up @@ -5751,8 +5767,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;
}

Expand Down
73 changes: 73 additions & 0 deletions src/CodeShellManager/Services/FolderOpener.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;

namespace CodeShellManager.Services;

/// <summary>
/// Opens a folder in the user's file manager, reliably. See "Opening a folder in Explorer"
/// in CLAUDE.md.
/// </summary>
/// <remarks>
/// It used to be <c>Process.Start("explorer.exe", folder)</c> — the folder as raw command-line
/// text — and Explorer silently opened <b>Documents</b> instead of the folder for two kinds of
/// path (measured, by asking the Shell which folder each new Explorer window showed):
/// <list type="bullet">
/// <item>forward slashes (<c>C:/repos/wt-feat</c>) — the form git prints, and so the form a
/// session opened through "New session in sibling worktree" stored;</item>
/// <item>a comma anywhere in the name — Explorer splits its arguments on commas, and quoting
/// the path does not stop it.</item>
/// </list>
/// 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, <c>&amp;</c>, <c>%</c>,
/// forward slashes, trailing separator, 8.3 short name. It also honours a replacement file
/// manager the user has set as the folder handler.
/// </remarks>
public static class FolderOpener
{
/// <summary>
/// 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.
/// </summary>
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; }
}

/// <summary>
/// Backslashes, absolute, no trailing separator (except a drive root). The form every
/// Windows tool accepts — use it for any folder that came from git.
/// </summary>
public static string WindowsPath(string folder)
{
string full = Path.GetFullPath(folder);
string trimmed = Path.TrimEndingDirectorySeparator(full);
return trimmed.EndsWith(':') ? trimmed + Path.DirectorySeparatorChar : trimmed;
}

/// <summary>
/// Opens <paramref name="folder"/> off the calling thread. Returns false when it was not
/// an existing folder or the shell refused; never throws.
/// </summary>
public static Task<bool> 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; }
});
}
Loading