diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 05:30:20 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 05:30:20 +0300 |
| commit | 226e87306deaba017d36583f51ae34e42b303aad (patch) | |
| tree | 2a1155689b75007c15d93735aedfa58db7a05edf /src | |
| parent | 17544c36ee397233e2f7b3319d86e3b517e3b5d0 (diff) | |
fix: preserve tab identity across restored sessions
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 18 | ||||
| -rw-r--r-- | src/app/api/layouts.rs | 7 | ||||
| -rw-r--r-- | src/app/api/tabs.rs | 12 | ||||
| -rw-r--r-- | src/app/creation.rs | 18 | ||||
| -rw-r--r-- | src/app/ids.rs | 32 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 10 | ||||
| -rw-r--r-- | src/app/mod.rs | 55 | ||||
| -rw-r--r-- | src/persist/restore.rs | 117 | ||||
| -rw-r--r-- | src/ui/mobile.rs | 68 | ||||
| -rw-r--r-- | src/workspace.rs | 4 | ||||
| -rw-r--r-- | src/workspace/aggregate.rs | 35 |
11 files changed, 315 insertions, 61 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index a7d99f35..99bd0ff6 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -31,6 +31,13 @@ fn is_completion_transition(change: &EffectiveStateChange) -> bool { ) } +fn public_tab_id_for_index(ws: &crate::workspace::Workspace, tab_idx: usize) -> Option<String> { + let tab_number = ws.public_tab_number(tab_idx)?; + Some(crate::workspace::public_tab_id_for_number( + &ws.id, tab_number, + )) +} + pub fn is_completion_transition_parts( previous_state: AgentState, state: AgentState, @@ -951,7 +958,8 @@ impl AppState { if let Some(ws) = self.workspaces.get_mut(idx) { let active_tab = ws.active_tab; ws.switch_tab(active_tab); - let tab_id = format!("{}:{}", workspace_id, active_tab + 1); + let tab_id = + public_tab_id_for_index(ws, active_tab).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); } self.tab_scroll_follow_active = true; @@ -994,7 +1002,8 @@ impl AppState { self.ensure_workspace_visible(ws_idx); if let Some(ws) = self.workspaces.get_mut(ws_idx) { ws.switch_tab(tab_idx); - let tab_id = format!("{}:{}", workspace_id, tab_idx + 1); + let tab_id = + public_tab_id_for_index(ws, tab_idx).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); } self.tab_scroll_follow_active = true; @@ -1092,7 +1101,7 @@ impl AppState { }; ws.switch_tab(idx); let workspace_id = ws.id.clone(); - let tab_id = format!("{}:{}", workspace_id, idx + 1); + let tab_id = public_tab_id_for_index(ws, idx).unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_focused(&workspace_id, &tab_id); self.mark_session_dirty(); self.tab_scroll_follow_active = true; @@ -1853,7 +1862,8 @@ impl AppState { return false; }; let workspace_id = ws.id.clone(); - let closing_tab_id = format!("{}:{}", workspace_id, ws.active_tab + 1); + let closing_tab_id = + public_tab_id_for_index(ws, ws.active_tab).unwrap_or_else(|| workspace_id.clone()); ws.close_active_tab(); self.remove_plugin_pane_records(pane_ids); self.remove_unattached_terminal_ids(terminal_ids); diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs index e397ccf8..9c492937 100644 --- a/src/app/api/layouts.rs +++ b/src/app/api/layouts.rs @@ -151,10 +151,9 @@ impl App { let closed_tab_id = self .public_tab_id(target_ws_idx, target_tab_idx) .unwrap_or_else(|| { - format!( - "{}:{}", - self.public_workspace_id(target_ws_idx), - target_tab_idx + 1 + crate::workspace::public_tab_id_for_number( + &self.public_workspace_id(target_ws_idx), + target_tab_idx + 1, ) }); let terminal_ids = self diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index c88a00d1..079c2ea4 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -105,11 +105,7 @@ impl App { if let Some(label) = label { let workspace_id = self.state.workspaces[ws_idx].id.clone(); let tab_id = self.public_tab_id(ws_idx, tab_idx).unwrap_or_else(|| { - format!( - "{}:t{}", - workspace_id, - crate::workspace::encode_public_number(tab_idx + 1) - ) + crate::workspace::public_tab_id_for_number(&workspace_id, tab_idx + 1) }); if let Some(tab) = self .state @@ -165,9 +161,9 @@ impl App { return tab_not_found(id, ¶ms.tab_id); }; let workspace_id = self.state.workspaces[ws_idx].id.clone(); - let tab_id = self - .public_tab_id(ws_idx, tab_idx) - .unwrap_or_else(|| format!("{}:{}", workspace_id, tab_idx + 1)); + let tab_id = self.public_tab_id(ws_idx, tab_idx).unwrap_or_else(|| { + crate::workspace::public_tab_id_for_number(&workspace_id, tab_idx + 1) + }); let Some(tab) = self .state .workspaces diff --git a/src/app/creation.rs b/src/app/creation.rs index a45dae03..e53bce00 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -123,13 +123,9 @@ impl App { self.state.mode = Mode::Terminal; } let workspace_id = self.state.workspaces[ws_idx].id.clone(); - let tab_id = self.public_tab_id(ws_idx, idx).unwrap_or_else(|| { - format!( - "{}:t{}", - workspace_id, - crate::workspace::encode_public_number(idx + 1) - ) - }); + let tab_id = self + .public_tab_id(ws_idx, idx) + .unwrap_or_else(|| crate::workspace::public_tab_id_for_number(&workspace_id, idx + 1)); let root_pane = self.state.workspaces[ws_idx].tabs[idx].root_pane.raw(); crate::logging::tab_created(&workspace_id, &tab_id, root_pane); self.schedule_session_save(); @@ -240,7 +236,7 @@ impl App { Some(crate::api::schema::TabInfo { tab_id: self.public_tab_id(ws_idx, tab_idx)?, workspace_id: self.public_workspace_id(ws_idx), - number: tab_idx + 1, + number: tab.number, label: tab.display_name(), focused: self.state.active == Some(ws_idx) && ws.active_tab == tab_idx, pane_count: tab.panes.len(), @@ -349,9 +345,9 @@ impl App { focused: self.state.active == Some(index), pane_count: ws.public_pane_numbers.len(), tab_count: ws.tabs.len(), - active_tab_id: self - .public_tab_id(index, ws.active_tab) - .unwrap_or_else(|| format!("{}:{}", ws.id, ws.active_tab + 1)), + active_tab_id: self.public_tab_id(index, ws.active_tab).unwrap_or_else(|| { + crate::workspace::public_tab_id_for_number(&ws.id, ws.active_tab + 1) + }), agent_status: pane_agent_status(agg_state, seen), worktree: ws .worktree_space() diff --git a/src/app/ids.rs b/src/app/ids.rs index 42cf47a8..9dc4371e 100644 --- a/src/app/ids.rs +++ b/src/app/ids.rs @@ -19,10 +19,8 @@ impl App { pub(super) fn public_tab_id(&self, ws_idx: usize, tab_idx: usize) -> Option<String> { let ws = self.state.workspaces.get(ws_idx)?; let tab_number = ws.public_tab_number(tab_idx)?; - Some(format!( - "{}:t{}", - ws.id, - crate::workspace::encode_public_number(tab_number) + Some(crate::workspace::public_tab_id_for_number( + &ws.id, tab_number, )) } @@ -33,10 +31,9 @@ impl App { ) -> Option<String> { let ws = self.state.workspaces.get(ws_idx)?; let pane_number = ws.public_pane_number(pane_id)?; - Some(format!( - "{}:p{}", - ws.id, - crate::workspace::encode_public_number(pane_number) + Some(crate::workspace::public_pane_id_for_number( + &ws.id, + pane_number, )) } @@ -80,18 +77,17 @@ impl App { let (ws_raw, tab_raw) = id.rsplit_once(':')?; let ws_idx = self.parse_workspace_id(ws_raw)?; - let tab_number = if let Some(encoded) = tab_raw.strip_prefix('t') { - crate::workspace::decode_public_number(encoded)? + let tab_idx = if let Some(encoded) = tab_raw.strip_prefix('t') { + let tab_number = crate::workspace::decode_public_number(encoded)?; + self.state + .workspaces + .get(ws_idx)? + .tabs + .iter() + .position(|tab| tab.number == tab_number)? } else { - tab_raw.parse::<usize>().ok()? + tab_raw.parse::<usize>().ok()?.checked_sub(1)? }; - let tab_idx = self - .state - .workspaces - .get(ws_idx)? - .tabs - .iter() - .position(|tab| tab.number == tab_number)?; self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?; Some((ws_idx, tab_idx)) } diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 3d106765..cff760bf 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -458,7 +458,15 @@ pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { tab.is_auto_named() && new_name == tab.number.to_string(); if !new_name.is_empty() && !keep_auto_name { tab.set_custom_name(new_name); - let tab_id = format!("{}:{}", workspace_id, active_tab + 1); + let tab_id = ws + .public_tab_number(active_tab) + .map(|number| { + crate::workspace::public_tab_id_for_number( + &workspace_id, + number, + ) + }) + .unwrap_or_else(|| workspace_id.clone()); crate::logging::tab_renamed(&workspace_id, &tab_id); state.mark_session_dirty(); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 356ecbab..36fedb71 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2932,6 +2932,61 @@ mod tests { } #[test] + fn tab_info_number_uses_stable_public_tab_number() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("api-tab-public-number"); + let removed_tab = workspace.test_add_tab(None); + let survivor_tab = workspace.test_add_tab(None); + let survivor_pane = workspace.tabs[survivor_tab].root_pane; + assert!(workspace.close_tab(removed_tab)); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + let survivor_idx = app.state.workspaces[0] + .find_tab_index_for_pane(survivor_pane) + .unwrap(); + + let tab = app.tab_info(0, survivor_idx).unwrap(); + + assert_eq!(tab.tab_id, format!("{}:t3", app.state.workspaces[0].id)); + assert_eq!(tab.number, 3); + assert_eq!(tab.label, "3"); + } + + #[test] + fn legacy_bare_tab_id_uses_tab_position_not_public_tab_number() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("legacy-tab-id"); + let removed_tab = workspace.test_add_tab(None); + workspace.test_add_tab(None); + let public_four_tab = workspace.test_add_tab(None); + let fourth_position_tab = workspace.test_add_tab(None); + let public_four_pane = workspace.tabs[public_four_tab].root_pane; + let fourth_position_pane = workspace.tabs[fourth_position_tab].root_pane; + assert!(workspace.close_tab(removed_tab)); + app.state.workspaces = vec![workspace]; + + let public_four_idx = app.state.workspaces[0] + .find_tab_index_for_pane(public_four_pane) + .unwrap(); + let fourth_position_idx = app.state.workspaces[0] + .find_tab_index_for_pane(fourth_position_pane) + .unwrap(); + + assert_eq!(app.state.workspaces[0].tabs[public_four_idx].number, 4); + assert_eq!(app.state.workspaces[0].tabs[fourth_position_idx].number, 5); + assert_eq!( + app.parse_tab_id(&format!("{}:t4", app.state.workspaces[0].id)), + Some((0, public_four_idx)) + ); + assert_eq!( + app.parse_tab_id(&format!("{}:4", app.state.workspaces[0].id)), + Some((0, fourth_position_idx)) + ); + } + + #[test] fn workspace_creation_in_navigate_mode_uses_selected_workspace_seed_cwd() { let mut app = test_app(); let mut first = Workspace::test_new("herdr"); diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 229d565d..7c046698 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -509,11 +509,7 @@ fn restore_tab( .map(|pane_id| { PaneLaunchEnv::from_extra(Vec::new()).with_identity( workspace_id.to_string(), - format!( - "{}:t{}", - workspace_id, - crate::workspace::encode_public_number(number) - ), + crate::workspace::public_tab_id_for_number(workspace_id, number), pane_id.to_string(), ) }) @@ -1312,6 +1308,117 @@ mod tests { assert_eq!(workspace.next_public_tab_number, 6); } + #[tokio::test] + async fn restore_with_gapped_public_tab_numbers_keeps_agent_panel_tab_indices() { + let cwd = std::env::current_dir().unwrap(); + let pane_snap = |id: &str| { + ( + id.parse::<u32>().unwrap(), + super::super::snapshot::PaneSnapshot { + cwd: cwd.clone(), + label: None, + agent_name: None, + agent_session: None, + launch_argv: None, + }, + ) + }; + let final_pane = super::super::snapshot::PaneSnapshot { + cwd: cwd.clone(), + label: Some("planner".into()), + agent_name: Some("planner".into()), + agent_session: Some(super::super::snapshot::PaneAgentSessionSnapshot { + source: "herdr:codex".into(), + agent: "codex".into(), + kind: crate::agent_resume::AgentSessionRefKind::Id, + value: "codex-session".into(), + }), + launch_argv: None, + }; + 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), (11, 2), (12, 3), (13, 4)]), + next_public_pane_number: 5, + public_tab_numbers: vec![1, 3, 4, 5], + next_public_tab_number: 6, + tabs: vec![ + TabSnapshot { + custom_name: None, + layout: LayoutSnapshot::Pane(10), + panes: HashMap::from([pane_snap("10")]), + zoomed: false, + focused: Some(10), + root_pane: Some(10), + }, + TabSnapshot { + custom_name: None, + layout: LayoutSnapshot::Pane(11), + panes: HashMap::from([pane_snap("11")]), + zoomed: false, + focused: Some(11), + root_pane: Some(11), + }, + TabSnapshot { + custom_name: None, + layout: LayoutSnapshot::Pane(12), + panes: HashMap::from([pane_snap("12")]), + zoomed: false, + focused: Some(12), + root_pane: Some(12), + }, + TabSnapshot { + custom_name: None, + layout: LayoutSnapshot::Pane(13), + panes: HashMap::from([(13, final_pane)]), + zoomed: false, + focused: Some(13), + root_pane: Some(13), + }, + ], + active_tab: 3, + }], + 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 agent_pane = workspace.tabs[3].root_pane; + let detail = workspace + .pane_details(&terminals) + .into_iter() + .find(|detail| detail.pane_id == agent_pane) + .expect("restored agent pane should be listed"); + + assert_eq!(workspace.active_tab, 3); + assert_eq!(workspace.tabs[3].number, 5); + assert_eq!(detail.tab_idx, 3); + assert_eq!(detail.agent_label, "planner"); + } + #[test] fn legacy_restore_precomputes_missing_public_pane_numbers() { let cwd = std::env::current_dir().unwrap(); diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index b8f23073..a9dc977f 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -290,7 +290,7 @@ fn render_header_status( } else { state_dot(state, seen, p) }; - let tab_label = format!("tab {}/{}", ws.active_tab + 1, ws.tabs.len()); + let tab_label = mobile_tab_status(ws); let row1 = Rect::new(area.x, area.y, area.width, 1); let tab_w = (tab_label.chars().count() as u16 + 1).min(area.width); let name_w = area.width.saturating_sub(tab_w); @@ -329,6 +329,17 @@ fn render_header_status( } } +fn mobile_tab_status(ws: &crate::workspace::Workspace) -> String { + let tab_number = ws + .public_tab_number(ws.active_tab) + .unwrap_or(ws.active_tab + 1); + if ws.tabs.len() <= 1 { + format!("tab {tab_number}") + } else { + format!("tab {tab_number} · {}/{}", ws.active_tab + 1, ws.tabs.len()) + } +} + fn render_switch_button(app: &AppState, frame: &mut Frame, area: Rect) { if area.width == 0 || area.height == 0 { return; @@ -471,10 +482,9 @@ fn render_mobile_switcher_content( ), ]); let detail = format!( - " {} · tab {}/{}", + " {} · {}", ws.branch().unwrap_or_else(|| "shell".into()), - ws.active_tab + 1, - ws.tabs.len() + mobile_tab_status(ws) ); render_two_line_item( frame, @@ -515,9 +525,9 @@ fn render_mobile_switcher_content( let active = idx == ws.active_tab; let bg = mobile_item_bg(false, active, p); let label = if tab.is_auto_named() { - format!("tab {}", idx + 1) + format!("tab {}", tab.display_name()) } else { - format!("{} · {}", idx + 1, tab.display_name()) + format!("{} · {}", tab.number, tab.display_name()) }; let title = Line::from(vec![ Span::styled(" ", Style::default().bg(bg)), @@ -965,6 +975,52 @@ mod tests { assert_eq!(mobile_agent_detail(&entry), " idle · pi"); } + #[test] + fn mobile_tab_status_uses_public_tab_number_and_position() { + let mut workspace = crate::workspace::Workspace::test_new("mobile-tabs"); + let removed_tab = workspace.test_add_tab(None); + workspace.test_add_tab(None); + assert!(workspace.close_tab(removed_tab)); + workspace.active_tab = 1; + + assert_eq!(mobile_tab_status(&workspace), "tab 3 · 2/2"); + } + + #[test] + fn mobile_switcher_uses_stable_public_tab_number_for_auto_tab_labels() { + let mut app = crate::app::state::AppState::test_new(); + let mut workspace = crate::workspace::Workspace::test_new("mobile-tabs"); + let removed_tab = workspace.test_add_tab(None); + workspace.test_add_tab(None); + assert!(workspace.close_tab(removed_tab)); + app.workspaces = vec![workspace]; + app.ensure_test_terminals(); + app.active = Some(0); + app.selected = 0; + app.view.mobile_header_rect = Rect::new(0, 0, 40, 2); + app.view.terminal_area = Rect::new(0, 2, 40, 18); + + let backend = ratatui::backend::TestBackend::new(40, 20); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + render_mobile_panel( + &app, + &TerminalRuntimeRegistry::new(), + frame, + Rect::new(0, 0, 40, 20), + ) + }) + .unwrap(); + + let row = (0..40) + .map(|x| terminal.backend().buffer()[(x, 10)].symbol()) + .collect::<String>(); + + assert!(row.contains("tab 3"), "mobile tab row: {row:?}"); + assert!(!row.contains("tab 2"), "mobile tab row: {row:?}"); + } + #[cfg(unix)] #[tokio::test] async fn mobile_header_uses_live_root_runtime_cwd_for_workspace_label() { diff --git a/src/workspace.rs b/src/workspace.rs index c4c91785..f06fe8ab 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -109,11 +109,11 @@ pub(crate) fn public_workspace_number(id: &str) -> Option<usize> { id.strip_prefix('w').and_then(decode_public_number) } -fn public_pane_id_for_number(workspace_id: &str, pane_number: usize) -> String { +pub(crate) fn public_pane_id_for_number(workspace_id: &str, pane_number: usize) -> String { format!("{workspace_id}:p{}", encode_public_number(pane_number)) } -fn public_tab_id_for_number(workspace_id: &str, tab_number: usize) -> String { +pub(crate) fn public_tab_id_for_number(workspace_id: &str, tab_number: usize) -> String { format!("{workspace_id}:t{}", encode_public_number(tab_number)) } diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs index c9c1266d..bf4c9408 100644 --- a/src/workspace/aggregate.rs +++ b/src/workspace/aggregate.rs @@ -48,7 +48,7 @@ impl Tab { let presentation = terminal.effective_presentation(); Some(PaneDetail { pane_id: *id, - tab_idx: self.number.saturating_sub(1), + tab_idx: 0, tab_label: self.display_name(), label: agent_label.clone(), agent_label, @@ -98,7 +98,15 @@ impl Workspace { let multi_tab = self.tabs.len() > 1; self.tabs .iter() - .flat_map(|tab| tab.pane_details(terminals)) + .enumerate() + .flat_map(|(tab_idx, tab)| { + tab.pane_details(terminals) + .into_iter() + .map(move |mut detail| { + detail.tab_idx = tab_idx; + detail + }) + }) .map(|mut detail| { if multi_tab { detail.label = format!("{}·{}", detail.tab_label, detail.agent_label); @@ -245,4 +253,27 @@ mod tests { ] ); } + + #[test] + fn pane_details_use_tab_vector_index_not_stable_public_tab_number() { + let mut ws = Workspace::test_new("test"); + let removed_tab = ws.test_add_tab(Some("removed")); + let survivor_tab = ws.test_add_tab(Some("survivor")); + let survivor_pane = ws.tabs[survivor_tab].root_pane; + assert!(ws.close_tab(removed_tab)); + + let mut terminals = HashMap::new(); + let mut terminal = terminal_for_pane(&ws, survivor_pane); + terminal.detected_agent = Some(Agent::Codex); + terminals.insert(terminal.id.clone(), terminal); + + let details = ws.pane_details(&terminals); + let survivor = details + .iter() + .find(|detail| detail.pane_id == survivor_pane) + .expect("surviving tab agent should be listed"); + + assert_eq!(ws.tabs[1].number, 3); + assert_eq!(survivor.tab_idx, 1); + } } |
