aboutsummaryrefslogtreecommitdiff
path: root/src/persist
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-12 19:07:07 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-12 19:10:09 +0300
commitf7a7da03fc27cc6fe4e6a44dabddcd303f939bb9 (patch)
tree69a00241e4500703bb723b64dd0060cc83730a0b /src/persist
parent53429206b736aa0fcfb1c5bfc1c0b11dcca3a059 (diff)
fix: stabilize public pane and tab ids
refs #569
Diffstat (limited to 'src/persist')
-rw-r--r--src/persist/restore.rs235
-rw-r--r--src/persist/snapshot.rs52
2 files changed, 279 insertions, 8 deletions
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
index d5661b30..ca1748e0 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -57,6 +57,7 @@ type RestoredTab = (
crate::workspace::Tab,
Vec<TerminalState>,
HashMap<TerminalId, TerminalRuntime>,
+ HashMap<PaneId, u32>,
);
type RestoreFailures<T> = (T, usize);
@@ -154,6 +155,35 @@ fn collect_snapshot_ids_inner(node: &LayoutSnapshot, ids: &mut Vec<u32>) {
}
}
+fn migrated_public_pane_numbers_by_old_raw(
+ snap: &WorkspaceSnapshot,
+ next_public_pane_number: &mut usize,
+) -> HashMap<u32, usize> {
+ let mut public_numbers = snap.public_pane_numbers.clone();
+ for tab in &snap.tabs {
+ let mut pane_ids = Vec::new();
+ collect_layout_snapshot_pane_ids(&tab.layout, &mut pane_ids);
+ for old_raw in pane_ids {
+ public_numbers.entry(old_raw).or_insert_with(|| {
+ let number = *next_public_pane_number;
+ *next_public_pane_number += 1;
+ number
+ });
+ }
+ }
+ public_numbers
+}
+
+fn collect_layout_snapshot_pane_ids(node: &LayoutSnapshot, ids: &mut Vec<u32>) {
+ match node {
+ LayoutSnapshot::Pane(id) => ids.push(*id),
+ LayoutSnapshot::Split { first, second, .. } => {
+ collect_layout_snapshot_pane_ids(first, ids);
+ collect_layout_snapshot_pane_ids(second, ids);
+ }
+ }
+}
+
#[cfg(unix)]
fn restore_with_imports_strict(
snapshot: &SessionSnapshot,
@@ -269,6 +299,7 @@ fn restore_with_imports_and_failures(
workspaces.push(workspace);
}
}
+ crate::workspace::reserve_workspace_ids(&workspaces);
((workspaces, terminals, terminal_runtimes), failed_imports)
}
@@ -284,8 +315,42 @@ fn restore_workspace(
let mut tabs = Vec::new();
let mut terminals = Vec::new();
let mut terminal_runtimes = HashMap::new();
+ let workspace_id = snap
+ .id
+ .clone()
+ .unwrap_or_else(crate::workspace::generate_workspace_id);
+ let mut next_public_pane_number = snap
+ .public_pane_numbers
+ .values()
+ .copied()
+ .max()
+ .and_then(|max| max.checked_add(1))
+ .unwrap_or(1)
+ .max(snap.next_public_pane_number);
+ let public_pane_numbers_by_old_raw =
+ migrated_public_pane_numbers_by_old_raw(snap, &mut next_public_pane_number);
+ let public_pane_ids_by_old_raw: HashMap<u32, String> = public_pane_numbers_by_old_raw
+ .iter()
+ .map(|(old_raw, public_number)| {
+ (
+ *old_raw,
+ format!(
+ "{}:p{}",
+ workspace_id,
+ crate::workspace::encode_public_number(*public_number)
+ ),
+ )
+ })
+ .collect();
let mut public_pane_numbers = HashMap::new();
- let mut next_public_pane_number = 1;
+ let mut next_public_tab_number = snap
+ .public_tab_numbers
+ .iter()
+ .copied()
+ .max()
+ .and_then(|max| max.checked_add(1))
+ .unwrap_or(1)
+ .max(snap.next_public_tab_number);
let mut failed_imports = 0;
for (idx, tab_snap) in snap.tabs.iter().enumerate() {
@@ -298,14 +363,33 @@ fn restore_workspace(
runtime_context,
resumed_agent_sessions,
imported_panes,
+ &public_pane_ids_by_old_raw,
);
failed_imports += tab_failed_imports;
- let Some((tab, restored_terminals, restored_runtimes)) = restored_tab else {
+ let Some((mut tab, restored_terminals, restored_runtimes, reverse_id_map)) = restored_tab
+ else {
continue;
};
+ if let Some(public_tab_number) = snap.public_tab_numbers.get(idx).copied() {
+ tab.number = public_tab_number;
+ }
+ next_public_tab_number = next_public_tab_number.max(tab.number + 1);
for pane_id in tab.layout.pane_ids() {
- public_pane_numbers.insert(pane_id, next_public_pane_number);
- next_public_pane_number += 1;
+ let public_number = public_pane_numbers_by_old_raw
+ .get(
+ &reverse_id_map
+ .get(&pane_id)
+ .copied()
+ .unwrap_or(pane_id.raw()),
+ )
+ .copied()
+ .unwrap_or_else(|| {
+ let number = next_public_pane_number;
+ next_public_pane_number += 1;
+ number
+ });
+ public_pane_numbers.insert(pane_id, public_number);
+ next_public_pane_number = next_public_pane_number.max(public_number + 1);
}
terminals.extend(restored_terminals);
terminal_runtimes.extend(restored_runtimes);
@@ -320,10 +404,7 @@ fn restore_workspace(
(
Some(Workspace {
- id: snap
- .id
- .clone()
- .unwrap_or_else(crate::workspace::generate_workspace_id),
+ id: workspace_id,
custom_name: snap.custom_name.clone(),
identity_cwd: snap.identity_cwd.clone(),
cached_git_branch: crate::workspace::git_branch(&snap.identity_cwd),
@@ -332,6 +413,7 @@ fn restore_workspace(
worktree_space,
public_pane_numbers,
next_public_pane_number,
+ next_public_tab_number,
active_tab: snap.active_tab.min(tabs.len().saturating_sub(1)),
tabs,
#[cfg(test)]
@@ -361,6 +443,7 @@ fn restore_tab(
runtime_context: &RestoreRuntimeContext<'_>,
resumed_agent_sessions: &mut HashSet<String>,
imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>,
+ public_pane_ids_by_old_raw: &HashMap<u32, String>,
) -> RestoreFailures<Option<RestoredTab>> {
let (node, id_map) = restore_node_remapped(&snap.layout);
let reverse_id_map: HashMap<PaneId, u32> = id_map
@@ -416,6 +499,9 @@ fn restore_tab(
.and_then(|plan| crate::detect::parse_agent_label(&plan.agent));
let old_pane_id = reverse_id_map.get(id).copied();
+ let public_pane_id = old_pane_id
+ .and_then(|old_id| public_pane_ids_by_old_raw.get(&old_id))
+ .map(String::as_str);
let imported_runtime = old_pane_id.and_then(|old_id| imported_panes.remove(&old_id));
let was_imported = imported_runtime.is_some();
let pending_native_agent_restore = if was_imported {
@@ -488,6 +574,7 @@ fn restore_tab(
runtime_context.events.clone(),
runtime_context.render_notify.clone(),
runtime_context.render_dirty.clone(),
+ public_pane_id,
)
}
@@ -505,6 +592,7 @@ fn restore_tab(
runtime_context.events.clone(),
runtime_context.render_notify.clone(),
runtime_context.render_dirty.clone(),
+ public_pane_id,
)
}
};
@@ -611,6 +699,7 @@ fn restore_tab(
},
terminals,
terminal_runtimes,
+ reverse_id_map,
)),
failed_imports,
)
@@ -1057,6 +1146,10 @@ mod tests {
custom_name: None,
identity_cwd: cwd.clone(),
worktree_space: None,
+ public_pane_numbers: HashMap::new(),
+ next_public_pane_number: 0,
+ public_tab_numbers: Vec::new(),
+ next_public_tab_number: 0,
tabs: vec![TabSnapshot {
custom_name: None,
layout: LayoutSnapshot::Pane(0),
@@ -1122,6 +1215,124 @@ mod tests {
}
#[tokio::test]
+ async fn restore_preserves_public_id_mapping_after_pane_id_remap() {
+ let cwd = std::env::current_dir().unwrap();
+ let snapshot = SessionSnapshot {
+ version: super::super::snapshot::SNAPSHOT_VERSION,
+ workspaces: vec![WorkspaceSnapshot {
+ id: Some("w1".into()),
+ custom_name: None,
+ identity_cwd: cwd.clone(),
+ worktree_space: None,
+ public_pane_numbers: HashMap::from([(10, 1), (20, 3)]),
+ next_public_pane_number: 4,
+ public_tab_numbers: vec![5],
+ next_public_tab_number: 6,
+ tabs: vec![TabSnapshot {
+ custom_name: None,
+ layout: LayoutSnapshot::Split {
+ direction: super::super::snapshot::DirectionSnapshot::Horizontal,
+ ratio: 0.5,
+ first: Box::new(LayoutSnapshot::Pane(10)),
+ second: Box::new(LayoutSnapshot::Pane(20)),
+ },
+ panes: HashMap::from([
+ (
+ 10,
+ super::super::snapshot::PaneSnapshot {
+ cwd: cwd.clone(),
+ label: None,
+ agent_name: None,
+ agent_session: None,
+ launch_argv: None,
+ },
+ ),
+ (
+ 20,
+ super::super::snapshot::PaneSnapshot {
+ cwd: cwd.clone(),
+ label: None,
+ agent_name: None,
+ agent_session: None,
+ launch_argv: None,
+ },
+ ),
+ ]),
+ zoomed: false,
+ focused: Some(10),
+ root_pane: Some(10),
+ }],
+ active_tab: 0,
+ }],
+ active: Some(0),
+ selected: 0,
+ agent_panel_scope: Default::default(),
+ sidebar_width: None,
+ sidebar_section_split: None,
+ collapsed_space_keys: Default::default(),
+ };
+ let (events, _event_rx) = mpsc::channel(4);
+
+ let (workspaces, _terminals, _runtimes) = restore(
+ &snapshot,
+ None,
+ 24,
+ 80,
+ 0,
+ test_restore_shell(),
+ crate::config::ShellModeConfig::NonLogin,
+ false,
+ events,
+ Arc::new(Notify::new()),
+ Arc::new(AtomicBool::new(false)),
+ );
+
+ let workspace = workspaces.first().expect("workspace should restore");
+ let mut public_numbers: Vec<_> = workspace.public_pane_numbers.values().copied().collect();
+ public_numbers.sort_unstable();
+ assert_eq!(public_numbers, vec![1, 3]);
+ assert_eq!(workspace.next_public_pane_number, 4);
+ assert_eq!(workspace.tabs[0].number, 5);
+ assert_eq!(workspace.next_public_tab_number, 6);
+ }
+
+ #[test]
+ fn legacy_restore_precomputes_missing_public_pane_numbers() {
+ let cwd = std::env::current_dir().unwrap();
+ let snapshot = WorkspaceSnapshot {
+ id: Some("w1".into()),
+ custom_name: None,
+ identity_cwd: cwd,
+ worktree_space: None,
+ public_pane_numbers: HashMap::new(),
+ next_public_pane_number: 0,
+ public_tab_numbers: Vec::new(),
+ next_public_tab_number: 0,
+ tabs: vec![TabSnapshot {
+ custom_name: None,
+ layout: LayoutSnapshot::Split {
+ direction: super::super::snapshot::DirectionSnapshot::Horizontal,
+ ratio: 0.5,
+ first: Box::new(LayoutSnapshot::Pane(10)),
+ second: Box::new(LayoutSnapshot::Pane(20)),
+ },
+ panes: HashMap::new(),
+ zoomed: false,
+ focused: Some(10),
+ root_pane: Some(10),
+ }],
+ active_tab: 0,
+ };
+ let mut next_public_pane_number = 1;
+
+ let public_numbers =
+ migrated_public_pane_numbers_by_old_raw(&snapshot, &mut next_public_pane_number);
+
+ assert_eq!(public_numbers, HashMap::from([(10, 1), (20, 2)]));
+ assert_eq!(next_public_pane_number, 3);
+ }
+
+ #[tokio::test]
#[cfg(unix)]
async fn native_agent_restore_defers_runtime_launch() {
let cwd = std::env::current_dir().unwrap();
@@ -1132,6 +1343,10 @@ mod tests {
custom_name: None,
identity_cwd: cwd.clone(),
worktree_space: None,
+ public_pane_numbers: HashMap::new(),
+ next_public_pane_number: 0,
+ public_tab_numbers: Vec::new(),
+ next_public_tab_number: 0,
tabs: vec![TabSnapshot {
custom_name: None,
layout: LayoutSnapshot::Pane(0),
@@ -1333,6 +1548,10 @@ mod tests {
custom_name: None,
identity_cwd: cwd,
worktree_space: None,
+ public_pane_numbers: HashMap::new(),
+ next_public_pane_number: 0,
+ public_tab_numbers: Vec::new(),
+ next_public_tab_number: 0,
tabs: vec![TabSnapshot {
custom_name: None,
layout: LayoutSnapshot::Pane(0),
diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs
index c04c280f..765fa2b6 100644
--- a/src/persist/snapshot.rs
+++ b/src/persist/snapshot.rs
@@ -57,6 +57,14 @@ pub struct WorkspaceSnapshot {
pub identity_cwd: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree_space: Option<crate::workspace::WorktreeSpaceMembership>,
+ #[serde(default)]
+ pub public_pane_numbers: HashMap<u32, usize>,
+ #[serde(default)]
+ pub next_public_pane_number: usize,
+ #[serde(default)]
+ pub public_tab_numbers: Vec<usize>,
+ #[serde(default)]
+ pub next_public_tab_number: usize,
pub tabs: Vec<TabSnapshot>,
#[serde(default)]
pub active_tab: usize,
@@ -150,6 +158,10 @@ impl From<LegacyWorkspaceSnapshot> for WorkspaceSnapshot {
custom_name: snap.custom_name,
identity_cwd,
worktree_space: None,
+ public_pane_numbers: HashMap::new(),
+ next_public_pane_number: 0,
+ public_tab_numbers: Vec::new(),
+ next_public_tab_number: 0,
tabs: vec![tab],
active_tab: 0,
}
@@ -284,6 +296,14 @@ fn capture_workspace(
.resolved_identity_cwd_from(terminals, terminal_runtimes)
.unwrap_or_else(|| ws.identity_cwd.clone()),
worktree_space: ws.worktree_space.clone(),
+ public_pane_numbers: ws
+ .public_pane_numbers
+ .iter()
+ .map(|(pane_id, number)| (pane_id.raw(), *number))
+ .collect(),
+ next_public_pane_number: ws.next_public_pane_number,
+ public_tab_numbers: ws.tabs.iter().map(|tab| tab.number).collect(),
+ next_public_tab_number: ws.next_public_tab_number,
tabs: ws
.tabs
.iter()
@@ -616,6 +636,10 @@ mod tests {
custom_name: Some("pi-mono".to_string()),
identity_cwd: PathBuf::from("/home/can/Projects/herdr"),
worktree_space: None,
+ public_pane_numbers: HashMap::from([(0, 1), (1, 2)]),
+ next_public_pane_number: 3,
+ public_tab_numbers: vec![1],
+ next_public_tab_number: 2,
tabs: vec![TabSnapshot {
custom_name: Some("api".to_string()),
layout: LayoutSnapshot::Split {
@@ -930,6 +954,30 @@ mod tests {
}
#[test]
+ fn capture_contract_tracks_public_id_counters() {
+ let mut state = state_with_workspaces(&["one"]);
+ let second = state.workspaces[0].test_split(Direction::Horizontal);
+ let third = state.workspaces[0].test_split(Direction::Vertical);
+ let second_tab = state.workspaces[0].test_add_tab(None);
+
+ state.workspaces[0].close_pane(second);
+
+ let snapshot = capture_from_state(&state);
+ let workspace = &snapshot.workspaces[0];
+ assert_eq!(
+ workspace.public_pane_numbers,
+ HashMap::from([
+ (state.workspaces[0].tabs[0].root_pane.raw(), 1),
+ (third.raw(), 3),
+ (state.workspaces[0].tabs[second_tab].root_pane.raw(), 4),
+ ])
+ );
+ assert_eq!(workspace.next_public_pane_number, 5);
+ assert_eq!(workspace.public_tab_numbers, vec![1, 2]);
+ assert_eq!(workspace.next_public_tab_number, 3);
+ }
+
+ #[test]
fn capture_contract_tracks_workspace_identity_and_pane_cwds() {
let mut state = state_with_workspaces(&["one"]);
let root = state.workspaces[0].tabs[0].root_pane;
@@ -1153,6 +1201,10 @@ mod tests {
custom_name: Some("fallback test".to_string()),
identity_cwd: PathBuf::from("/tmp"),
worktree_space: None,
+ public_pane_numbers: HashMap::new(),
+ next_public_pane_number: 0,
+ public_tab_numbers: Vec::new(),
+ next_public_tab_number: 0,
tabs: vec![TabSnapshot {
custom_name: None,
layout: LayoutSnapshot::Split {