Skip to content

Commit d133390

Browse files
claudesinelaw
authored andcommitted
Restore each co-tenant window to its own file on reboot
Two co-tenant workspaces over one root were collapsing to a single window on a cold relaunch, and the surviving window could restore the wrong file: - `build_persisted_window_shells` dropped every persisted window whose root matched the active window's directory (the old "one session per directory" guard). Given `pick_active_window_for_cwd`'s semantics that guard now only ever fired on genuine co-tenants, silently discarding one of them whenever the launch cwd hosted more than one window. - The active (picked) window adopted the picked session's label, root, plugin state and authority spec but NOT its `stable_id`, so it minted a fresh id. `load_by_id` then missed its own file and fell back to the freshest sibling snapshot — restoring the wrong file. Both windows now keep their durable identity and restore their own file. Adds a dedicated round-trip integration test (its own binary because it sets the process-global `XDG_DATA_HOME`) covering extract -> save -> cold reboot, and refreshes the now-stale "rooted at parent directory" command/menu/keybinding descriptions to the co-tenant wording. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQYut8xrWsYyQffc4D84dz
1 parent 512426f commit d133390

6 files changed

Lines changed: 166 additions & 14 deletions

File tree

crates/fresh-editor/locales/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,7 @@
570570
"cmd.explorer_rename": "File Explorer: Rename",
571571
"cmd.explorer_rename_desc": "Rename the selected file or directory",
572572
"cmd.extract_tab_to_new_workspace": "Extract Tab to New Workspace",
573-
"cmd.extract_tab_to_new_workspace_desc": "Move the current tab's buffer into a new orchestrator workspace rooted at its parent directory",
573+
"cmd.extract_tab_to_new_workspace_desc": "Move the current tab into its own new workspace over the same project (a co-tenant window)",
574574
"cmd.find_in_selection": "Find in Selection",
575575
"cmd.find_in_selection_desc": "Search only within the current selection",
576576
"cmd.find_next": "Find Next",

crates/fresh-editor/src/app/editor_init.rs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,6 @@ fn build_persisted_window_shells(
443443
persisted_env: Option<&crate::app::orchestrator_persistence::PersistedWindows>,
444444
active_came_from_pick: bool,
445445
active_window_id: fresh_core::WindowId,
446-
active_root: &std::path::Path,
447446
width: u16,
448447
height: u16,
449448
dir_context: &DirectoryContext,
@@ -465,7 +464,6 @@ fn build_persisted_window_shells(
465464
// project's id-1 base, which would collide. Re-id that collider onto a
466465
// fresh id so it survives as an inactive shell instead of being
467466
// shadowed/dropped (issue #2056 cross-project case).
468-
let active_root_key = crate::app::orchestrator_persistence::canonical_key(active_root);
469467
let mut next_fresh_id = env
470468
.next_id
471469
.max(env.windows.iter().map(|w| w.id).max().unwrap_or(0) + 1)
@@ -475,12 +473,13 @@ fn build_persisted_window_shells(
475473
if active_came_from_pick && ps.id == active_window_id.0 {
476474
continue;
477475
}
478-
// One session per directory: never seed a shell that resolves to the
479-
// active window's own directory (the clean-base case where the cwd has
480-
// a stale persisted window the pick didn't claim).
481-
if crate::app::orchestrator_persistence::canonical_key(&ps.root) == active_root_key {
482-
continue;
483-
}
476+
// NOTE: co-tenants — several sessions may share one root (a tab
477+
// extracted into its own window over the same project). Every
478+
// persisted entry other than the active pick becomes its own shell,
479+
// *including* siblings rooted at the active window's directory. The
480+
// old "one session per directory" guard that dropped same-root
481+
// entries here is gone: it silently discarded a co-tenant on reboot
482+
// whenever the launch cwd hosted more than one window.
484483
let id = if ps.id == active_window_id.0 {
485484
let fresh = fresh_core::WindowId(next_fresh_id);
486485
next_fresh_id += 1;
@@ -1345,6 +1344,16 @@ impl Editor {
13451344
active_win.event_logs = event_logs;
13461345
active_win.plugin_state = active_plugin_state;
13471346
active_win.authority_spec = active_authority_spec;
1347+
// Continue the picked session's durable identity so ITS own on-disk
1348+
// workspace (keyed by `stable_id`) restores into this window. Several
1349+
// co-tenants may share the root, so a window that kept the fresh id
1350+
// `Window::new` mints would miss its own file in `load_by_id` and fall
1351+
// back to the freshest sibling's snapshot — loading the wrong file.
1352+
// A clean-base launch (no pick) keeps that fresh id. Mirrors the
1353+
// background-shell adoption in `build_persisted_window_shells`.
1354+
if let Some(sid) = picked_active.and_then(|w| w.stable_id.clone()) {
1355+
active_win.stable_id = sid;
1356+
}
13481357
// Load prompt histories from disk for the active window.
13491358
// Each window has its own prompt-history rings.
13501359
active_win
@@ -1368,7 +1377,6 @@ impl Editor {
13681377
persisted_env.as_ref(),
13691378
picked_active.is_some(),
13701379
active_window_id,
1371-
&active_win.root,
13721380
width,
13731381
height,
13741382
&dir_context,

crates/fresh-editor/src/app/types/context_menu.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ pub enum TabContextMenuItem {
2727
CopyRelativePath,
2828
/// Copy the tab's absolute file path
2929
CopyFullPath,
30-
/// Move the tab's buffer into a new orchestrator workspace rooted at
31-
/// the file's parent directory
30+
/// Move the tab into its own new orchestrator workspace over the same
31+
/// project root (a co-tenant window)
3232
ExtractToNewWorkspace,
3333
}
3434

crates/fresh-editor/src/input/keybindings.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -603,8 +603,8 @@ pub enum Action {
603603
PrevSplit,
604604
NextWindow,
605605
PrevWindow,
606-
/// Move the focused tab's buffer into a new orchestrator workspace
607-
/// rooted at the file's parent directory.
606+
/// Move the focused tab into its own new orchestrator workspace over the
607+
/// same project root (a co-tenant window).
608608
ExtractTabToNewWorkspace,
609609
NextPane,
610610
PrevPane,

crates/fresh-editor/tests/e2e/extract_tab_to_workspace.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,3 +369,9 @@ fn extract_root_level_file_creates_co_tenant() {
369369
"the extracted co-tenant shares the source project root"
370370
);
371371
}
372+
373+
// NOTE: the load-bearing cold-reboot round-trip for co-tenants — two windows
374+
// over one root each restoring their OWN file — lives in the dedicated
375+
// `orchestrator_co_tenant_restore` integration binary. It mutates the
376+
// process-global `XDG_DATA_HOME` to isolate persistence, so it must NOT share
377+
// this binary's process with the thousands of other e2e tests.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//! Cold-reboot round-trip for co-tenant workspaces (multiple windows over one
2+
//! project root).
3+
//!
4+
//! This is the invariant the interactive Orchestrator dock could not be
5+
//! scripted to confirm: extract a tab into a second window over the *same*
6+
//! root, quit, relaunch — and each of the two co-tenant windows must restore
7+
//! its OWN file. The source keeps `alpha`, the extracted window keeps `beta`.
8+
//! Without a durable per-window identity the two on-disk files would collapse
9+
//! (both loading the freshest snapshot, so both showing `beta`) — exactly the
10+
//! "same buffer opened twice" hazard multiple-workspaces-per-root has to avoid.
11+
//!
12+
//! Lives in its own integration binary because it sets the process-global
13+
//! `XDG_DATA_HOME` to isolate persistence: workspace save/load key off
14+
//! `$XDG_DATA_HOME/fresh`, and the editor's boot discovery reads the same
15+
//! `DirectoryContext::data_dir`, so both must point at one isolated tree. A
16+
//! shared-process test binary (e.g. the big `e2e_tests`) can't host that
17+
//! global mutation without poisoning its siblings. Linux-gated:
18+
//! `dirs::data_dir()` ignores `XDG_DATA_HOME` off Linux.
19+
#![cfg(target_os = "linux")]
20+
21+
use fresh::config::Config;
22+
use fresh::config_io::DirectoryContext;
23+
use fresh::model::filesystem::StdFileSystem;
24+
use std::collections::BTreeSet;
25+
use std::path::{Path, PathBuf};
26+
use std::sync::Arc;
27+
28+
/// Isolate ALL editor persistence into `base`: `$XDG_DATA_HOME/fresh` is where
29+
/// workspace save/load live, and the returned `DirectoryContext`'s `data_dir`
30+
/// is the SAME path — so session-1 saves and session-2 boot discovery agree,
31+
/// inside the test's temp tree.
32+
fn isolated_dir_context(base: &Path) -> DirectoryContext {
33+
let xdg_data = base.join("xdg-data");
34+
std::fs::create_dir_all(&xdg_data).unwrap();
35+
std::env::set_var("XDG_DATA_HOME", &xdg_data);
36+
DirectoryContext {
37+
data_dir: xdg_data.join("fresh"),
38+
config_dir: base.join("config"),
39+
home_dir: Some(base.join("home")),
40+
documents_dir: None,
41+
downloads_dir: None,
42+
}
43+
}
44+
45+
fn editor_in(project: &Path, dir_context: &DirectoryContext) -> fresh::app::Editor {
46+
let filesystem: Arc<dyn fresh::model::filesystem::FileSystem + Send + Sync> =
47+
Arc::new(StdFileSystem);
48+
let config = Config {
49+
check_for_updates: false,
50+
..Config::default()
51+
};
52+
fresh::app::Editor::for_test(
53+
config,
54+
80,
55+
24,
56+
Some(project.to_path_buf()),
57+
dir_context.clone(),
58+
fresh::view::color_support::ColorCapability::TrueColor,
59+
filesystem,
60+
None,
61+
None,
62+
false,
63+
false,
64+
)
65+
.unwrap()
66+
}
67+
68+
#[test]
69+
fn co_tenants_persist_and_restore_each_own_file() {
70+
let sandbox = tempfile::tempdir().unwrap();
71+
let dir_context = isolated_dir_context(sandbox.path());
72+
let project = sandbox.path().join("project");
73+
std::fs::create_dir(&project).unwrap();
74+
let project = project.canonicalize().unwrap();
75+
let alpha = project.join("alpha.txt");
76+
let beta = project.join("beta.txt");
77+
std::fs::write(&alpha, "alpha\n").unwrap();
78+
std::fs::write(&beta, "beta\n").unwrap();
79+
80+
// Session 1: open both files (beta focused last), extract beta into a
81+
// co-tenant over the same root, then persist every window.
82+
{
83+
let mut e1 = editor_in(&project, &dir_context);
84+
e1.open_file(&alpha).unwrap();
85+
e1.open_file(&beta).unwrap();
86+
let beta_buffer = e1.active_buffer();
87+
e1.extract_tab_to_new_workspace(beta_buffer);
88+
// Source window keeps alpha; the new co-tenant took beta.
89+
e1.save_all_windows_workspaces().unwrap();
90+
}
91+
92+
// Two distinct on-disk workspace files now describe this one root.
93+
let workspaces_dir = dir_context.data_dir.join("workspaces");
94+
let files: Vec<_> = std::fs::read_dir(&workspaces_dir)
95+
.unwrap()
96+
.filter_map(|e| e.ok())
97+
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
98+
.collect();
99+
assert_eq!(
100+
files.len(),
101+
2,
102+
"two co-tenant windows must persist as two distinct workspace files, got: {:?}",
103+
files.iter().map(|e| e.file_name()).collect::<Vec<_>>()
104+
);
105+
106+
// Session 2: cold reboot at the same root. Boot discovery rebuilds both
107+
// co-tenant windows; restore the foreground (as a real launch does) and
108+
// lazily materialize the background co-tenant.
109+
let mut e2 = editor_in(&project, &dir_context);
110+
e2.restore_active_window_on_launch(false).unwrap();
111+
e2.materialize_all_windows();
112+
113+
let mut file_sets: Vec<BTreeSet<PathBuf>> = Vec::new();
114+
for id in 1..=64u64 {
115+
if let Some(w) = e2.session(fresh_core::WindowId(id)) {
116+
if w.root != project {
117+
continue;
118+
}
119+
let paths: BTreeSet<PathBuf> = w.buffers.paths().into_iter().collect();
120+
if !paths.is_empty() {
121+
file_sets.push(paths);
122+
}
123+
}
124+
}
125+
file_sets.sort();
126+
127+
// Each co-tenant restored its OWN file: one window holds alpha, the other
128+
// beta — not two copies of the freshest snapshot, and not a single
129+
// survivor with the other silently dropped.
130+
assert_eq!(
131+
file_sets,
132+
vec![
133+
BTreeSet::from([alpha.clone()]),
134+
BTreeSet::from([beta.clone()]),
135+
],
136+
"each restored co-tenant must reopen exactly its own file"
137+
);
138+
}

0 commit comments

Comments
 (0)