Skip to content

Commit 4a94ca1

Browse files
maui1911claude
andcommitted
fix(rs): sync soft-close to sidebar + double-click reopens last closed session
Two related session-lifecycle bugs surfaced after PR #221 made the spawn-side properly persist session rows for paths that previously fell into the free-floating branch: 1. **Closed sessions never appeared in the sidebar's history disclosure.** `AppShell::soft_close_session` correctly stamped `closed_at` on the in-memory `ProjectsConfig` and saved it to disk, but never pushed the result to the `Sidebar` entity. The sidebar keeps its own `projects` copy and builds the per-worktree history bucket off it at render time, so the new `closed_at` row stayed invisible until the next sidebar-side mutation (add / remove project, rename) happened to refresh the snapshot. Mirror the `replace_projects` + `cx.notify()` push `reopen_session` already does for the reverse transition. Plumbs `cx` through `soft_close_session` (sole caller is `close_tab`, which already has a `Context<Self>`). 2. **Double-clicking a worktree always spawned a fresh session, even when the user already had a closed one for it.** Expected behaviour (matching C# `MainViewModel.OnTreeDoubleClick`): double-click is the "pick up where I left off" gesture; explicit "New session" / "New <agent> session" menu rows are the only way to force a brand-new spawn. The `SidebarEvent::OpenSession` handler now resolves in three tiers: 1. An open tab for this `wd` exists → focus it. 2. A soft-closed session for this `wd` exists → reopen the most-recently-closed one. Path match uses `paths_match` so a slash-convention mismatch between the event payload and the persisted `worktree_path` doesn't drop the hit (same rule PR #221 applied to `locate_project_for_path`). Newest-first ordering matches the sidebar history disclosure, so a double-click reopens exactly the row the user sees at the top. 3. Otherwise → spawn a fresh session (same path `force_new` takes unconditionally). `force_new` still short-circuits straight to spawn — the explicit-new gesture is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7d2a033 commit 4a94ca1

1 file changed

Lines changed: 76 additions & 11 deletions

File tree

src/app.rs

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -814,16 +814,25 @@ impl AppShell {
814814
auto_type,
815815
force_new,
816816
} => {
817-
// Focus-or-open by default: walk every group's
818-
// tabs for one whose `working_directory` matches
819-
// and activate it. Only fall through to a fresh
820-
// spawn when nothing matches *or* the caller
821-
// explicitly asked for `force_new` (the
822-
// "New session" / "New Claude session" menu
823-
// rows). This is what the user means by
824-
// "clicking a worktree shouldn't pile up new
825-
// sessions every time".
817+
// Three-tier "open session" resolution, mirroring
818+
// the C# `MainViewModel.OnTreeDoubleClick` flow:
819+
//
820+
// 1. An open tab pinned to this `wd` already
821+
// exists → just focus it.
822+
// 2. No open tab, but the worktree has a
823+
// soft-closed session in history → reopen
824+
// the most-recently-closed one. This is the
825+
// user expectation for "double-click the
826+
// same branch again" — pick up where you
827+
// left off, don't keep stamping fresh
828+
// free-floating sessions.
829+
// 3. Otherwise → spawn a brand new session
830+
// (the same path `force_new` takes
831+
// unconditionally for explicit "New
832+
// session" / "New <agent> session" menu
833+
// rows).
826834
if !*force_new {
835+
// Tier 1: focus an already-open tab.
827836
let mut focus_target: Option<(usize, usize)> = None;
828837
for (g_idx, group) in this.groups.iter().enumerate() {
829838
for (t_idx, tab) in group.tabs.iter().enumerate() {
@@ -840,7 +849,48 @@ impl AppShell {
840849
this.activate_tab(g_idx, t_idx, window, cx);
841850
return;
842851
}
852+
853+
// Tier 2: reopen the most-recent closed
854+
// session for this worktree. Path matching
855+
// goes through `paths_match` so a `\\`-vs-`/`
856+
// mismatch between the spawn event and the
857+
// persisted `worktree_path` doesn't drop the
858+
// history hit (same reason
859+
// `locate_project_for_path` uses it). Sort
860+
// by `closed_at` desc — newest first —
861+
// mirroring the sidebar's history disclosure
862+
// ordering so a double-click and the visible
863+
// top-of-history row stay in lockstep. ISO-
864+
// 8601 sorts lexicographically the same way
865+
// it sorts chronologically, so a plain str
866+
// cmp is enough.
867+
let wd_str = working_directory.to_string_lossy();
868+
let most_recent_closed = this
869+
.projects
870+
.projects
871+
.iter()
872+
.flat_map(|p| p.sessions.iter())
873+
.filter(|s| s.closed_at.is_some())
874+
.filter(|s| {
875+
codescope_core::path_canon::paths_match(
876+
&s.worktree_path,
877+
&wd_str,
878+
)
879+
})
880+
.max_by(|a, b| {
881+
a.closed_at.as_deref().cmp(&b.closed_at.as_deref())
882+
})
883+
.map(|s| s.id.clone());
884+
if let Some(session_id) = most_recent_closed {
885+
this.reopen_session(session_id, window, cx);
886+
return;
887+
}
843888
}
889+
890+
// Tier 3: spawn a fresh session. Reached when
891+
// `force_new` was set explicitly OR when there
892+
// is neither an open tab nor a closed session
893+
// for this worktree.
844894
this.spawn_tab_in(
845895
Some(working_directory.clone()),
846896
Some(title.clone()),
@@ -2491,7 +2541,16 @@ impl AppShell {
24912541
/// `projects.json` (path matched no project at spawn time) is a
24922542
/// silent no-op rather than an error. Reload-then-mutate-then-save
24932543
/// keeps us in sync with concurrent sidebar writes.
2494-
fn soft_close_session(&mut self, session_id: &str) {
2544+
///
2545+
/// After a successful close we push the freshly-mutated
2546+
/// `ProjectsConfig` into the sidebar entity — the sidebar keeps
2547+
/// its own copy of `projects` and builds the per-worktree
2548+
/// closed-session disclosure off it at render time, so without
2549+
/// this push the new `closed_at` stamp would only become visible
2550+
/// after the next sidebar-side mutation refreshed the snapshot.
2551+
/// Mirrors the same `replace_projects` + `cx.notify()` dance
2552+
/// `reopen_session` does for the reverse transition.
2553+
fn soft_close_session(&mut self, session_id: &str, cx: &mut Context<Self>) {
24952554
match ProjectsConfig::load(&self.paths) {
24962555
Ok(cfg) => {
24972556
self.projects = cfg;
@@ -2504,7 +2563,13 @@ impl AppShell {
25042563
Ok(_pruned) => {
25052564
if let Err(err) = self.projects.save(&self.paths) {
25062565
eprintln!("warning: failed to persist session soft-close: {err:#}");
2566+
return;
25072567
}
2568+
let projects_for_sidebar = self.projects.clone();
2569+
self.sidebar.update(cx, |sidebar, cx| {
2570+
sidebar.replace_projects(projects_for_sidebar);
2571+
cx.notify();
2572+
});
25082573
}
25092574
Err(_) => {
25102575
// Free-floating session (no project context at spawn
@@ -3215,7 +3280,7 @@ impl AppShell {
32153280
// failure logs and proceeds; the in-memory tab state is the
32163281
// source of truth for what's on screen.
32173282
if !codescope_session_id.is_empty() {
3218-
self.soft_close_session(&codescope_session_id);
3283+
self.soft_close_session(&codescope_session_id, cx);
32193284
}
32203285
let Some(group) = self.groups.get_mut(group_idx) else { return };
32213286
if tab_idx >= group.tabs.len() {

0 commit comments

Comments
 (0)