From 7e91c4cd933201e1f578f89e87cd8b20bc87c658 Mon Sep 17 00:00:00 2001 From: Eric Yue Date: Mon, 21 Sep 2026 08:13:34 +0800 Subject: [PATCH] perf: index navigator tabs and panes per endpoint (#4426) --- src/client/shell/aggregate_navigation.rs | 36 ++++-- src/client/shell/tests/copy.rs | 153 +++++++++++++++++++---- 2 files changed, 157 insertions(+), 32 deletions(-) diff --git a/src/client/shell/aggregate_navigation.rs b/src/client/shell/aggregate_navigation.rs index 9d3108cc..b1a4d35f 100644 --- a/src/client/shell/aggregate_navigation.rs +++ b/src/client/shell/aggregate_navigation.rs @@ -304,23 +304,39 @@ pub(super) fn navigator_rows( .iter() .map(|agent| (agent.pane_id.as_str(), agent)) .collect::>(); + // Build endpoint-local indexes once. Walk each bucket in snapshot + // order so interleaved input and overlapping IDs on other endpoints + // retain their existing navigation order and targets. + let mut tabs_by_workspace = HashMap::new(); + for tab in &snapshot.tabs { + tabs_by_workspace + .entry(tab.workspace_id.as_str()) + .or_insert_with(Vec::new) + .push(tab); + } + let mut panes_by_tab = HashMap::new(); + for pane in &snapshot.panes { + panes_by_tab + .entry(pane.tab_id.as_str()) + .or_insert_with(Vec::new) + .push(pane); + } for workspace in &snapshot.workspaces { let workspace_matches = endpoint_query_matches || text(&workspace.label) || workspace.branch.as_deref().is_some_and(text); let mut children = Vec::new(); - let workspace_tabs = snapshot - .tabs - .iter() - .filter(|tab| tab.workspace_id == workspace.workspace_id); - let multiple_tabs = workspace_tabs.clone().nth(1).is_some(); + let workspace_tabs = tabs_by_workspace + .get(workspace.workspace_id.as_str()) + .map(Vec::as_slice) + .unwrap_or_default(); + let multiple_tabs = workspace_tabs.len() > 1; for tab in workspace_tabs { let tab_matches = workspace_matches || text(&tab.label); - let tab_panes = snapshot - .panes - .iter() - .filter(|pane| pane.tab_id == tab.tab_id) - .collect::>(); + let tab_panes = panes_by_tab + .get(tab.tab_id.as_str()) + .map(Vec::as_slice) + .unwrap_or_default(); for (index, pane) in tab_panes.iter().enumerate() { let agent = agents.get(pane.pane_id.as_str()).copied(); let status = agent diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index bdd192a6..617af89b 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -1534,32 +1534,141 @@ fn navigator_narrow_layout_and_long_search_stay_inside_the_popup() { } } +fn navigator_scale_snapshot(workspaces: usize, tabs: usize, panes: usize) -> ClientShellSnapshot { + let mut result = snapshot(); + let workspace_template = result.workspaces[0].clone(); + let tab_template = result.tabs[0].clone(); + let pane_template = result.panes[0].clone(); + result.workspaces.clear(); + result.tabs.clear(); + result.panes.clear(); + for w in 0..workspaces { + let mut workspace = workspace_template.clone(); + workspace.workspace_id = format!("workspace_{w}"); + workspace.active_tab_id = format!("tab_{w}_0"); + workspace.number = w + 1; + workspace.label = format!("workspace {w}"); + workspace.focused = w == 0; + for t in 0..tabs { + let mut tab = tab_template.clone(); + tab.workspace_id = workspace.workspace_id.clone(); + tab.tab_id = format!("tab_{w}_{t}"); + tab.number = t + 1; + tab.label = format!("tab {t}"); + tab.focused = w == 0 && t == 0; + for p in 0..panes { + let mut pane = pane_template.clone(); + pane.workspace_id = workspace.workspace_id.clone(); + pane.tab_id = tab.tab_id.clone(); + pane.pane_id = format!("pane_{w}_{t}_{p}"); + pane.label = Some(format!("terminal {p}")); + pane.focused = w == 0 && t == 0 && p == 0; + result.panes.push(pane); + } + result.tabs.push(tab); + } + result.workspaces.push(workspace); + } + result.focused_workspace_id = Some(result.workspaces[0].workspace_id.clone()); + result.focused_tab_id = Some(result.tabs[0].tab_id.clone()); + result.focused_pane_id = Some(result.panes[0].pane_id.clone()); + result +} + +#[test] +fn navigator_grouping_keeps_snapshot_order_with_interleaved_tabs_and_panes() { + let mut snapshot = navigator_scale_snapshot(2, 2, 2); + snapshot.tabs.swap(1, 2); + snapshot.panes.reverse(); + let expected = snapshot + .workspaces + .iter() + .flat_map(|workspace| { + snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace.workspace_id) + .flat_map(|tab| { + snapshot + .panes + .iter() + .filter(|pane| pane.tab_id == tab.tab_id) + .map(|pane| pane.pane_id.clone()) + }) + }) + .collect::>(); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + let remote = SavedSshEndpoint::new("Remote", "dev@example.invalid", "test").unwrap(); + let remote_id = ClientEndpointId::Ssh(remote.id.clone()); + state.set_endpoint_catalog(&[remote]); + state.set_endpoint_status(&remote_id, ClientEndpointStatus::Online); + state.set_endpoint_snapshot(&remote_id, Box::new(snapshot.clone())); + state.set_snapshot(Box::new(snapshot)); + state.open_navigator_overlay(); + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_ref() else { + panic!("navigator") + }; + let rows = + render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator); + let actual = rows + .iter() + .filter_map(|row| match &row.target { + ClientNavigatorTarget::Pane { + endpoint_id, + pane_id, + } => { + assert!(endpoint_id == &state.active_endpoint_id || endpoint_id == &remote_id); + Some((endpoint_id.clone(), pane_id.clone())) + } + _ => None, + }) + .collect::>(); + let expected = [state.active_endpoint_id.clone(), remote_id] + .into_iter() + .flat_map(|endpoint| { + expected + .iter() + .map(move |pane| (endpoint.clone(), pane.clone())) + }) + .collect::>(); + assert_eq!(actual, expected); +} + #[test] #[ignore = "manual navigator composition scaling profile"] fn navigator_render_scale_profile() { - for panes in [1, 15, 52] { - let mut snapshot = snapshot(); - for index in 1..panes { - let mut pane = snapshot.panes[0].clone(); - pane.pane_id = format!("pane_{index}_extra"); - pane.focused = false; - snapshot.panes.push(pane); + for (workspaces, tabs, panes) in [ + (1, 1, 1), + (1, 1, 15), + (1, 1, 52), + (1, 1, 512), + (1, 128, 4), + (64, 2, 4), + (128, 4, 1), + ] { + for query in ["", "terminal 0"] { + let mut state = + ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(navigator_scale_snapshot(workspaces, tabs, panes))); + let mut pane_surface = surface(); + pane_surface.panes[0].pane_id = "pane_0_0_0".into(); + state.set_pane_surface(pane_surface); + state.open_navigator_overlay(); + if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() { + navigator.query = query.into(); + } + for _ in 0..20 { + std::hint::black_box(state.compose(106, 30).expect("navigator frame")); + } + let start = std::time::Instant::now(); + for _ in 0..1000 { + std::hint::black_box(state.compose(106, 30).expect("navigator frame")); + } + eprintln!( + "navigator: {workspaces}x{tabs}x{panes}, query={query:?}, {:.1} us/frame", + start.elapsed().as_secs_f64() * 1000.0 + ); } - let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); - state.set_snapshot(Box::new(snapshot)); - state.set_pane_surface(surface()); - state.open_navigator_overlay(); - for _ in 0..20 { - std::hint::black_box(state.compose(106, 30).expect("navigator frame")); - } - let start = std::time::Instant::now(); - for _ in 0..1000 { - std::hint::black_box(state.compose(106, 30).expect("navigator frame")); - } - eprintln!( - "navigator: {panes} panes, {:.1} us/frame", - start.elapsed().as_secs_f64() * 1000.0 - ); } }