aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-09-09 23:56:53 +0300
committerGitHub <noreply@github.com>2026-09-09 23:56:53 +0300
commit120c682008a85dc17d7a13cc619a2fd5d25f3924 (patch)
tree0119dfbe37bba91053f985ba60f3ee399d2ea797 /src/client
parent985d3442b4b8a6f2e25a62821aab58d63d8ce2cf (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/client')
-rw-r--r--src/client/shell/overlays.rs76
-rw-r--r--src/client/shell/tests/copy.rs137
-rw-r--r--src/client/shell/tests/endpoints.rs16
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());