diff options
| author | akbash <akbash@herdr.dev> | 2026-09-09 23:56:53 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-09 23:56:53 +0300 |
| commit | 120c682008a85dc17d7a13cc619a2fd5d25f3924 (patch) | |
| tree | 0119dfbe37bba91053f985ba60f3ee399d2ea797 /src | |
| parent | 985d3442b4b8a6f2e25a62821aab58d63d8ce2cf (diff) | |
fix: restore connected session navigator tree glyphs (#3861)
refs #3843
Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/shell/overlays.rs | 76 | ||||
| -rw-r--r-- | src/client/shell/tests/copy.rs | 137 | ||||
| -rw-r--r-- | src/client/shell/tests/endpoints.rs | 16 |
3 files changed, 199 insertions, 30 deletions
diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs index faf252a8..4418c2c3 100644 --- a/src/client/shell/overlays.rs +++ b/src/client/shell/overlays.rs @@ -673,6 +673,24 @@ fn render_rename_overlay( }) } +/// Mark following siblings in preorder without rescanning descendants for each row. +/// The reverse stack contains at most one entry per depth; every entry is pushed +/// and popped at most once, so this pass is linear in the number of rows. +fn navigator_following_siblings(rows: &[ClientNavigatorRow]) -> Vec<bool> { + let mut following = vec![false; rows.len()]; + let mut depths = Vec::new(); + for (index, row) in rows.iter().enumerate().rev() { + while depths.last().is_some_and(|depth| *depth > row.depth) { + depths.pop(); + } + following[index] = depths.last() == Some(&row.depth); + if !following[index] { + depths.push(row.depth); + } + } + following +} + fn render_navigator_overlay( b: &mut Buffer, n: &ClientNavigatorOverlay, @@ -746,15 +764,17 @@ fn render_navigator_overlay( .max(selected.saturating_sub(body.height.saturating_sub(1) as usize)) .min(selected) .min(max); + let following_siblings = navigator_following_siblings(&rows); + let mut ancestor_siblings = Vec::new(); + let federated = endpoints.len() > 1; let mut row_hits = Vec::new(); - for (vis, (ix, r)) in rows - .iter() - .enumerate() - .skip(scroll) - .take(body.height as usize) - .enumerate() - { - let rect = Rect::new(body.x, body.y + vis as u16, body.width, 1); + for (ix, r) in rows.iter().enumerate().take(scroll + body.height as usize) { + ancestor_siblings.truncate(usize::from(r.depth)); + ancestor_siblings.push(following_siblings[ix]); + if ix < scroll { + continue; + } + let rect = Rect::new(body.x, body.y + (ix - scroll) as u16, body.width, 1); row_hits.push((rect, r.target.clone())); let st = if r.stale { Style::default() @@ -777,7 +797,7 @@ fn render_navigator_overlay( }; b.set_style(rect, st); let tree = match &r.target { - ClientNavigatorTarget::Machine { .. } => "▾", + ClientNavigatorTarget::Machine { .. } => "▾".to_owned(), ClientNavigatorTarget::Workspace { endpoint_id, workspace_id, @@ -785,32 +805,28 @@ fn render_navigator_overlay( .expanded_workspaces .contains(&(endpoint_id.clone(), workspace_id.clone())) => { - if r.depth == 0 { - "▾" - } else { - " ▾" - } + if r.depth == 0 { "▾" } else { " ▾" }.to_owned() } ClientNavigatorTarget::Workspace { .. } => { - if r.depth == 0 { - "▸" - } else { - " ▸" - } + if r.depth == 0 { "▸" } else { " ▸" }.to_owned() } - ClientNavigatorTarget::Tab { .. } => { - if r.depth == 1 { - "└──" - } else { - " └──" + ClientNavigatorTarget::Tab { .. } | ClientNavigatorTarget::Pane { .. } => { + // Machines and workspaces keep their existing caret decoration. + // Connected branches begin below each workspace. + let mut prefix = if federated { " " } else { "" }.to_owned(); + for &following in ancestor_siblings + .iter() + .take(usize::from(r.depth)) + .skip(if federated { 2 } else { 1 }) + { + prefix.push_str(if following { "│ " } else { " " }); } - } - ClientNavigatorTarget::Pane { .. } => { - if r.depth == 2 { - " └──" + prefix.push_str(if following_siblings[ix] { + "├──" } else { - " └──" - } + "└──" + }); + prefix } }; let current = if r.current { "◆ " } else { "" }; diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index 95413f8a..fa0635af 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -732,6 +732,143 @@ fn copy_search_owns_prompt_repeat_highlights_selection_and_restore() { } #[test] +fn navigator_renders_connected_siblings_and_ancestor_lines() { + let mut snapshot = snapshot(); + snapshot.focused_pane_id = None; + snapshot.tabs[0].label = "editor".into(); + snapshot.panes[0].label = Some("agent".into()); + snapshot.panes[0].focused = false; + let mut shell = snapshot.panes[0].clone(); + shell.pane_id = "pane_shell".into(); + shell.label = Some("shell".into()); + snapshot.panes.push(shell); + for label in ["notes", "logs"] { + let mut tab = snapshot.tabs[0].clone(); + tab.tab_id = format!("tab_{label}"); + tab.label = label.into(); + tab.focused = false; + tab.number = snapshot.tabs.len() + 1; + let mut pane = snapshot.panes[0].clone(); + pane.pane_id = format!("pane_{label}"); + pane.tab_id = tab.tab_id.clone(); + pane.label = Some(label.into()); + pane.focused = false; + snapshot.tabs.push(tab); + snapshot.panes.push(pane); + } + let mut workspace = snapshot.workspaces[0].clone(); + workspace.workspace_id = "ws_2".into(); + workspace.active_tab_id = "tab_last".into(); + workspace.label = "second".into(); + workspace.number = 2; + workspace.focused = false; + let mut tab = snapshot.tabs[0].clone(); + tab.workspace_id = workspace.workspace_id.clone(); + tab.tab_id = workspace.active_tab_id.clone(); + tab.label = "last".into(); + tab.focused = false; + let mut pane = snapshot.panes[0].clone(); + pane.workspace_id = workspace.workspace_id.clone(); + pane.tab_id = tab.tab_id.clone(); + pane.pane_id = "pane_last".into(); + snapshot.workspaces.push(workspace); + snapshot.tabs.push(tab); + snapshot.panes.push(pane); + 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(); + let prefixes = |state: &mut ClientShellState, height| { + let frame = state.compose(106, height).expect("navigator frame"); + state + .hits + .navigator_rows + .iter() + .map(|(rect, _)| { + frame.cells[rect.y as usize * frame.width as usize + rect.x as usize + 1..] + .iter() + .take(6) + .map(|cell| cell.symbol.as_str()) + .collect::<String>() + }) + .collect::<Vec<_>>() + }; + assert_eq!( + prefixes(&mut state, 30), + [ + "▾ clie", + "├── ed", + "│ ├──", + "│ └──", + "├── no", + "│ └──", + "└── lo", + " └──", + "▾ seco", + "└── la", + " └──" + ] + ); + + // The editor ancestor is above this viewport; the logs sibling is below it. + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("expected navigator"); + }; + navigator.scroll = 2; + navigator.selected = Some(ClientNavigatorTarget::Pane { + endpoint_id: state.active_endpoint_id.clone(), + pane_id: "pane_shell".into(), + }); + assert_eq!( + prefixes(&mut state, 12), + ["│ ├──", "│ └──", "├── no", "│ └──"] + ); + + // Excluded siblings must not leave dangling continuation lines. + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("expected navigator"); + }; + navigator.query = "shell".into(); + navigator.scroll = 0; + assert_eq!(prefixes(&mut state, 30), ["▾ clie", "└── ed", " └──"]); + let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else { + panic!("expected navigator"); + }; + navigator.query.clear(); + navigator.expanded_workspaces.clear(); + assert_eq!(prefixes(&mut state, 30), ["▸ clie", "▸ seco"]); +} + +#[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); + } + 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 + ); + } +} + +#[test] fn navigator_owns_search_mouse_selection_and_stable_target_focus() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); state.set_snapshot(Box::new(snapshot())); diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index e425b794..0b69135e 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -1008,6 +1008,22 @@ fn navigator_uses_machine_parents_only_for_federated_clients() { })); assert_eq!(rows.iter().filter(|row| row.current).count(), 1); + let frame = state.compose(106, 30).expect("federated navigator"); + for (rect, target) in &state.hits.navigator_rows { + let expected = match target { + ClientNavigatorTarget::Machine { .. } => " ▾ ", + ClientNavigatorTarget::Workspace { .. } => " ▾ ", + ClientNavigatorTarget::Tab { .. } => " └── ", + ClientNavigatorTarget::Pane { .. } => " └── ", + }; + let prefix = frame.cells[rect.y as usize * frame.width as usize + rect.x as usize..] + .iter() + .take(expected.chars().count()) + .map(|cell| cell.symbol.as_str()) + .collect::<String>(); + assert_eq!(prefix, expected, "{target:?}"); + } + let mut local = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); local.set_snapshot(Box::new(snapshot())); local.set_pane_surface(surface()); |
