mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 16:01:04 +00:00
feat: show every agent and terminal in the go to picker
This commit is contained in:
@@ -62,6 +62,8 @@ Herdr はマウスネイティブです。キーバインドをひとつも覚
|
||||
|
||||
完全なキーマップとバインドの構文は[キーバインドリファレンス](/ja/docs/configuration/#キーバインド)にあります。
|
||||
|
||||
Goto ピッカー (`prefix+g`) は、各エージェントとターミナルをワークスペースごとに一行ずつ表示します。分割タブのペインも隠さず表示されます。行をクリックするか Enter を押すと、そのペインへ直接移動します。`/` で名前、エージェントの種類、タブ、ワークスペース、ブランチ、パスを検索できます。検索入力中でなければ上下矢印キーまたは `j/k` で行を移動します。左右矢印キーは前後のワークスペースの最初のエージェントまたはターミナルへ移動し、端では折り返しません。検索入力中は左右矢印キーでテキストカーソルを動かします。`b/w/i/d` で blocked、working、idle、done のエージェントに絞り込み、`a` ですべてのターミナルに戻します。選択した移動先の場所とパスは一覧の下に表示されます。一覧が収まらない場合は、マウスホイール、スクロールバーのトラックのクリック、つまみのドラッグで、移動先を開かずにスクロールできます。
|
||||
|
||||
## テキスト欄の編集
|
||||
|
||||
Herdr の名前入力、worktree のブランチ名、フィルター、コピーモードの検索欄では、カーソル位置で編集できます。以下は Herdr 自身の入力欄の操作であり、ペイン内のシェルやエージェントの操作は変わりません。
|
||||
|
||||
@@ -62,6 +62,8 @@ Workspaces and session:
|
||||
|
||||
The full keymap and the binding syntax live in the [keybinding reference](/docs/configuration/#keybindings).
|
||||
|
||||
The Goto picker (`prefix+g`) lists each agent or terminal on its own row, grouped by workspace. Split tabs never hide their panes behind another row. Click a row or press Enter to jump directly to it. Press `/` to search names, agent kinds, tabs, workspaces, branches, or paths; use Up/Down or `j/k` to move between rows when not typing a search. Left/Right jumps to the first agent or terminal in the previous/next workspace section, without wrapping. While editing a search, Left/Right moves the text cursor instead. Outside search, `b/w/i/d` filter blocked, working, idle, or done agents, and `a` shows all terminals again. The selected destination's location and path appear below the list. When the list overflows, use the mouse wheel, click the scrollbar track, or drag its thumb to scroll without opening a destination.
|
||||
|
||||
## Editing text fields
|
||||
|
||||
Herdr's naming dialogs, worktree branch fields, filters, and copy-mode search support editing at the cursor. These shortcuts apply to Herdr-owned fields, not to the shell or agent inside a pane.
|
||||
|
||||
@@ -62,6 +62,8 @@ Herdr 是鼠标原生的。你可以点击窗格、标签页、工作区和智
|
||||
|
||||
完整键位和绑定语法见[按键绑定参考](/zh-cn/docs/configuration/#按键绑定)。
|
||||
|
||||
Goto 选择器 (`prefix+g`) 按工作区分组,每个代理或终端各占一行,分割标签页中的窗格也会直接显示。点击一行或按回车即可跳转到对应窗格。按 `/` 搜索名称、代理类型、标签页、工作区、分支或路径;未输入搜索时,用上下方向键或 `j/k` 在各行之间移动,左右方向键跳到上一个或下一个工作区中的第一个代理或终端,到达两端后不会循环。编辑搜索内容时,左右方向键只移动文本光标。用 `b/w/i/d` 筛选 blocked、working、idle 或 done 状态的代理,按 `a` 重新显示所有终端。所选目标的位置和路径显示在列表下方。列表超出可视区域时,可以使用鼠标滚轮、点击滚动条轨道或拖动滑块来滚动,而不会打开目标。
|
||||
|
||||
## 编辑文本输入框
|
||||
|
||||
Herdr 的命名对话框、worktree 分支名、筛选框和复制模式搜索框支持在光标处编辑。以下快捷键适用于 Herdr 自己的输入框,不改变窗格内 shell 或智能体的按键行为。
|
||||
|
||||
@@ -299,49 +299,92 @@ pub(super) fn navigator_rows(
|
||||
let endpoint_query_matches = !query.is_empty() && text(&endpoint.label);
|
||||
let mut endpoint_rows = Vec::new();
|
||||
if let Some(snapshot) = endpoint.snapshot.as_deref() {
|
||||
let agents = snapshot
|
||||
.agents
|
||||
.iter()
|
||||
.map(|agent| (agent.pane_id.as_str(), agent))
|
||||
.collect::<HashMap<_, _>>();
|
||||
for workspace in &snapshot.workspaces {
|
||||
let workspace_meta = workspace.branch.clone().unwrap_or_default();
|
||||
let workspace_matches = endpoint_query_matches
|
||||
|| text(&workspace.label)
|
||||
|| workspace.branch.as_deref().is_some_and(text);
|
||||
let mut children = Vec::new();
|
||||
for tab in snapshot
|
||||
let workspace_tabs = snapshot
|
||||
.tabs
|
||||
.iter()
|
||||
.filter(|tab| tab.workspace_id == workspace.workspace_id)
|
||||
{
|
||||
let mut panes = Vec::new();
|
||||
for (index, pane) in snapshot
|
||||
.filter(|tab| tab.workspace_id == workspace.workspace_id);
|
||||
let multiple_tabs = workspace_tabs.clone().nth(1).is_some();
|
||||
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)
|
||||
.enumerate()
|
||||
{
|
||||
let agent = snapshot
|
||||
.agents
|
||||
.iter()
|
||||
.find(|agent| agent.pane_id == pane.pane_id);
|
||||
.collect::<Vec<_>>();
|
||||
for (index, pane) in tab_panes.iter().enumerate() {
|
||||
let agent = agents.get(pane.pane_id.as_str()).copied();
|
||||
let status = agent
|
||||
.map_or(crate::api::schema::AgentStatus::Unknown, |agent| {
|
||||
agent.agent_status
|
||||
});
|
||||
let label = pane
|
||||
let agent_kind = agent.and_then(|agent| {
|
||||
agent.agent.as_deref().or(agent.display_agent.as_deref())
|
||||
});
|
||||
let name = pane
|
||||
.label
|
||||
.clone()
|
||||
.or_else(|| agent.and_then(|agent| agent.name.clone()))
|
||||
.or_else(|| agent.and_then(|agent| agent.display_agent.clone()))
|
||||
.or_else(|| agent.and_then(|agent| agent.title.clone()))
|
||||
.unwrap_or_else(|| format!("pane {}", index + 1));
|
||||
.as_deref()
|
||||
.or_else(|| agent.and_then(|agent| agent.name.as_deref()));
|
||||
let title = agent.and_then(|agent| {
|
||||
agent
|
||||
.title
|
||||
.as_deref()
|
||||
.or(agent.terminal_title_stripped.as_deref())
|
||||
});
|
||||
let tab_name = (tab.custom_label || tab.label.parse::<usize>().is_err())
|
||||
.then_some(tab.label.as_str());
|
||||
let label = if tab_panes.len() == 1 {
|
||||
match name.or(tab_name).or(title) {
|
||||
Some(label) => label.to_owned(),
|
||||
None if multiple_tabs => {
|
||||
format!("{} · {}", agent_kind.unwrap_or("terminal"), tab.label)
|
||||
}
|
||||
None => workspace.label.clone(),
|
||||
}
|
||||
} else {
|
||||
let pane_name = name.or(title).or(agent_kind).unwrap_or("terminal");
|
||||
match tab_name {
|
||||
Some(tab_name) if tab_name != pane_name => {
|
||||
format!("{tab_name} · {pane_name} · {}", index + 1)
|
||||
}
|
||||
_ => format!("{pane_name} · {}", index + 1),
|
||||
}
|
||||
};
|
||||
let meta = pane
|
||||
.foreground_cwd
|
||||
.clone()
|
||||
.or_else(|| pane.cwd.clone())
|
||||
.as_deref()
|
||||
.or(pane.cwd.as_deref())
|
||||
.unwrap_or_default();
|
||||
if !filtering
|
||||
|| filter(status)
|
||||
&& (endpoint_query_matches || text(&label) || text(&meta))
|
||||
if filter(status)
|
||||
&& (tab_matches
|
||||
|| text(&label)
|
||||
|| text(meta)
|
||||
|| pane.cwd.as_deref().is_some_and(text)
|
||||
|| agent_kind.is_some_and(text)
|
||||
|| title.is_some_and(text)
|
||||
|| agent
|
||||
.and_then(|agent| agent.display_agent.as_deref())
|
||||
.is_some_and(text)
|
||||
|| text(&pane.pane_id))
|
||||
{
|
||||
panes.push(ClientNavigatorRow {
|
||||
depth: 2 + depth_offset,
|
||||
children.push(ClientNavigatorRow {
|
||||
depth: 1 + depth_offset,
|
||||
label,
|
||||
meta,
|
||||
meta: meta.to_owned(),
|
||||
detail: format!(
|
||||
"{} / {} / {}",
|
||||
workspace.label, tab.label, pane.pane_id
|
||||
),
|
||||
agent: agent_kind.map(str::to_owned),
|
||||
status: Some(status),
|
||||
stale,
|
||||
current: endpoint.endpoint_id == *active_endpoint_id
|
||||
@@ -353,40 +396,17 @@ pub(super) fn navigator_rows(
|
||||
});
|
||||
}
|
||||
}
|
||||
if !filtering
|
||||
|| filter(tab.agent_status) && (endpoint_query_matches || text(&tab.label))
|
||||
|| !panes.is_empty()
|
||||
{
|
||||
children.push(ClientNavigatorRow {
|
||||
depth: 1 + depth_offset,
|
||||
label: tab.label.clone(),
|
||||
meta: format!(
|
||||
"{} panes",
|
||||
snapshot
|
||||
.panes
|
||||
.iter()
|
||||
.filter(|pane| pane.tab_id == tab.tab_id)
|
||||
.count()
|
||||
),
|
||||
status: None,
|
||||
stale,
|
||||
current: false,
|
||||
target: ClientNavigatorTarget::Tab {
|
||||
endpoint_id: endpoint.endpoint_id.clone(),
|
||||
tab_id: tab.tab_id.clone(),
|
||||
},
|
||||
});
|
||||
children.extend(panes);
|
||||
}
|
||||
}
|
||||
let workspace_matches = filter(workspace.agent_status)
|
||||
&& (endpoint_query_matches || text(&workspace.label) || text(&workspace_meta));
|
||||
if !filtering || workspace_matches || !children.is_empty() {
|
||||
let key = (endpoint.endpoint_id.clone(), workspace.workspace_id.clone());
|
||||
if !filtering
|
||||
|| !children.is_empty()
|
||||
|| (navigator.filter.is_none() && !query.is_empty() && workspace_matches)
|
||||
{
|
||||
endpoint_rows.push(ClientNavigatorRow {
|
||||
depth: depth_offset,
|
||||
label: workspace.label.clone(),
|
||||
meta: workspace_meta,
|
||||
meta: workspace.branch.clone().unwrap_or_default(),
|
||||
detail: workspace.new_workspace_cwd.clone(),
|
||||
agent: None,
|
||||
status: None,
|
||||
stale,
|
||||
current: false,
|
||||
@@ -395,9 +415,7 @@ pub(super) fn navigator_rows(
|
||||
workspace_id: workspace.workspace_id.clone(),
|
||||
},
|
||||
});
|
||||
if navigator.expanded_workspaces.contains(&key) || filtering {
|
||||
endpoint_rows.extend(children);
|
||||
}
|
||||
endpoint_rows.extend(children);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,6 +425,8 @@ pub(super) fn navigator_rows(
|
||||
depth: 0,
|
||||
label: endpoint.label.to_owned(),
|
||||
meta: String::new(),
|
||||
detail: String::new(),
|
||||
agent: None,
|
||||
status: None,
|
||||
stale,
|
||||
current: false,
|
||||
@@ -427,7 +447,10 @@ pub(super) fn navigator_selected_index(
|
||||
) -> Option<usize> {
|
||||
match navigator.selected.as_ref() {
|
||||
Some(target) => rows.iter().position(|row| row.target == *target),
|
||||
None => (!rows.is_empty()).then_some(0),
|
||||
None => rows
|
||||
.iter()
|
||||
.position(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
|
||||
.or_else(|| (!rows.is_empty()).then_some(0)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -649,6 +649,8 @@ impl ClientShellState {
|
||||
self.hits.navigator_popup = rendered.navigator_popup;
|
||||
self.hits.navigator_search = rendered.navigator_search;
|
||||
self.hits.navigator_rows = rendered.navigator_rows;
|
||||
self.hits.navigator_scrollbar = rendered.navigator_scrollbar;
|
||||
self.hits.navigator_scroll_metrics = rendered.navigator_scroll_metrics;
|
||||
self.hits.worktree_search = rendered.worktree_search;
|
||||
self.hits.worktree_rows = rendered.worktree_rows;
|
||||
self.hits.help_popup = rendered.help_popup;
|
||||
|
||||
+43
-10
@@ -1032,6 +1032,22 @@ impl ClientShellState {
|
||||
}
|
||||
return;
|
||||
}
|
||||
Some(ClientChromeDrag::NavigatorScrollbar { grab_row_offset }) => {
|
||||
if let Some(metrics) = self.hits.navigator_scroll_metrics {
|
||||
let offset = crate::ui::scrollbar_offset_from_drag_row(
|
||||
metrics,
|
||||
self.hits.navigator_scrollbar,
|
||||
mouse.row,
|
||||
*grab_row_offset,
|
||||
);
|
||||
self.scroll_navigator_to(
|
||||
metrics.max_offset_from_bottom.saturating_sub(offset),
|
||||
metrics.viewport_rows,
|
||||
);
|
||||
outcome.repaint = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
Some(ClientChromeDrag::HelpScrollbar { grab_row_offset }) => {
|
||||
if let (Some(metrics), Some(ClientShellOverlay::Help(help))) =
|
||||
(self.hits.help_scroll_metrics, self.overlay.as_mut())
|
||||
@@ -1317,6 +1333,7 @@ impl ClientShellState {
|
||||
ClientChromeDrag::WorkspaceScrollbar { .. }
|
||||
| ClientChromeDrag::AgentScrollbar { .. }
|
||||
| ClientChromeDrag::HelpScrollbar { .. }
|
||||
| ClientChromeDrag::NavigatorScrollbar { .. }
|
||||
| ClientChromeDrag::ProductAnnouncementScrollbar { .. }
|
||||
| ClientChromeDrag::ReleaseNotesScrollbar { .. } => {}
|
||||
}
|
||||
@@ -1613,7 +1630,29 @@ impl ClientShellState {
|
||||
}
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
if super::contains(self.hits.navigator_search, point) {
|
||||
if super::contains(self.hits.navigator_scrollbar, point) {
|
||||
if let Some(metrics) = self.hits.navigator_scroll_metrics {
|
||||
if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(
|
||||
metrics,
|
||||
self.hits.navigator_scrollbar,
|
||||
mouse.row,
|
||||
) {
|
||||
self.chrome_drag =
|
||||
Some(ClientChromeDrag::NavigatorScrollbar { grab_row_offset });
|
||||
} else {
|
||||
let offset = crate::ui::scrollbar_offset_from_row(
|
||||
metrics,
|
||||
self.hits.navigator_scrollbar,
|
||||
mouse.row,
|
||||
);
|
||||
self.scroll_navigator_to(
|
||||
metrics.max_offset_from_bottom.saturating_sub(offset),
|
||||
metrics.viewport_rows,
|
||||
);
|
||||
outcome.repaint = true;
|
||||
}
|
||||
}
|
||||
} else if super::contains(self.hits.navigator_search, point) {
|
||||
if let Some(ClientShellOverlay::Navigator(navigator)) =
|
||||
self.overlay.as_mut()
|
||||
{
|
||||
@@ -1621,19 +1660,13 @@ impl ClientShellState {
|
||||
navigator.filter = None;
|
||||
}
|
||||
outcome.repaint = true;
|
||||
} else if let Some((rect, target)) = row_hit {
|
||||
} else if let Some((_, target)) = row_hit {
|
||||
if let Some(ClientShellOverlay::Navigator(navigator)) =
|
||||
self.overlay.as_mut()
|
||||
{
|
||||
navigator.selected = Some(target.clone());
|
||||
}
|
||||
let workspace = matches!(target, ClientNavigatorTarget::Workspace { .. });
|
||||
if workspace && mouse.column <= rect.x.saturating_add(3) {
|
||||
self.toggle_selected_navigator_workspace();
|
||||
outcome.repaint = true;
|
||||
} else {
|
||||
self.accept_navigator_selection(outcome);
|
||||
navigator.selected = Some(target);
|
||||
}
|
||||
self.accept_navigator_selection(outcome);
|
||||
} else if !super::contains(self.hits.navigator_popup, point) {
|
||||
self.overlay = None;
|
||||
outcome.repaint = true;
|
||||
|
||||
@@ -191,21 +191,12 @@ impl ClientShellState {
|
||||
}
|
||||
|
||||
pub(super) fn open_navigator_overlay(&mut self) {
|
||||
let expanded_workspaces =
|
||||
super::aggregate_navigation::cached_endpoint_snapshots(&self.endpoints)
|
||||
.flat_map(|endpoint| {
|
||||
endpoint.snapshot.workspaces.iter().map(move |workspace| {
|
||||
(endpoint.endpoint_id.clone(), workspace.workspace_id.clone())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut navigator = ClientNavigatorOverlay {
|
||||
query: TextEditor::default(),
|
||||
search_focused: false,
|
||||
selected: None,
|
||||
scroll: 0,
|
||||
filter: None,
|
||||
expanded_workspaces,
|
||||
};
|
||||
let rows =
|
||||
render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, &navigator);
|
||||
@@ -233,6 +224,55 @@ impl ClientShellState {
|
||||
navigator.selected = Some(rows[next].target.clone());
|
||||
}
|
||||
|
||||
pub(super) fn scroll_navigator_to(&mut self, scroll: usize, viewport_rows: usize) {
|
||||
let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let rows =
|
||||
render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, navigator);
|
||||
let viewport_rows = viewport_rows.max(1);
|
||||
navigator.scroll = scroll.min(rows.len().saturating_sub(viewport_rows));
|
||||
let selected =
|
||||
super::aggregate_navigation::navigator_selected_index(&rows, navigator).unwrap_or(0);
|
||||
// Keep the selection in the dragged viewport so rendering does not snap back to it.
|
||||
let selected = selected.clamp(navigator.scroll, navigator.scroll + viewport_rows - 1);
|
||||
navigator.selected = rows.get(selected).map(|row| row.target.clone());
|
||||
}
|
||||
|
||||
fn move_navigator_workspace(&mut self, forward: bool) {
|
||||
let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let rows =
|
||||
render::client_navigator_rows(&self.endpoints, &self.active_endpoint_id, navigator);
|
||||
let Some(selected) =
|
||||
super::aggregate_navigation::navigator_selected_index(&rows, navigator)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let section = rows[..=selected]
|
||||
.iter()
|
||||
.rposition(|row| !matches!(row.target, ClientNavigatorTarget::Pane { .. }))
|
||||
.unwrap_or(selected);
|
||||
let mut destinations = rows.windows(2).enumerate().filter(|(index, pair)| {
|
||||
matches!(pair[0].target, ClientNavigatorTarget::Workspace { .. })
|
||||
&& matches!(pair[1].target, ClientNavigatorTarget::Pane { .. })
|
||||
&& if forward {
|
||||
*index > section
|
||||
} else {
|
||||
*index < section
|
||||
}
|
||||
});
|
||||
let destination = if forward {
|
||||
destinations.next()
|
||||
} else {
|
||||
destinations.next_back()
|
||||
};
|
||||
if let Some((_, pair)) = destination {
|
||||
navigator.selected = Some(pair[1].target.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn accept_navigator_selection(&mut self, outcome: &mut ClientShellInput) {
|
||||
let target = self.overlay.as_ref().and_then(|overlay| match overlay {
|
||||
ClientShellOverlay::Navigator(navigator) => {
|
||||
@@ -260,12 +300,6 @@ impl ClientShellState {
|
||||
ClientEndpointFocusTarget::Workspace(workspace_id),
|
||||
outcome,
|
||||
),
|
||||
ClientNavigatorTarget::Tab {
|
||||
endpoint_id,
|
||||
tab_id,
|
||||
} => {
|
||||
self.focus_or_activate(endpoint_id, ClientEndpointFocusTarget::Tab(tab_id), outcome)
|
||||
}
|
||||
ClientNavigatorTarget::Pane {
|
||||
endpoint_id,
|
||||
pane_id,
|
||||
@@ -281,37 +315,6 @@ impl ClientShellState {
|
||||
outcome.repaint = true;
|
||||
}
|
||||
|
||||
pub(super) fn toggle_selected_navigator_workspace(&mut self) {
|
||||
let workspace_key = self.overlay.as_ref().and_then(|overlay| match overlay {
|
||||
ClientShellOverlay::Navigator(navigator) => {
|
||||
let rows = render::client_navigator_rows(
|
||||
&self.endpoints,
|
||||
&self.active_endpoint_id,
|
||||
navigator,
|
||||
);
|
||||
super::aggregate_navigation::selected_navigator_target(&rows, navigator).and_then(
|
||||
|target| match target {
|
||||
ClientNavigatorTarget::Workspace {
|
||||
endpoint_id,
|
||||
workspace_id,
|
||||
} => Some((endpoint_id, workspace_id)),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let (Some(workspace_key), Some(ClientShellOverlay::Navigator(navigator))) =
|
||||
(workspace_key, self.overlay.as_mut())
|
||||
{
|
||||
if !navigator.expanded_workspaces.remove(&workspace_key) {
|
||||
navigator.expanded_workspaces.insert(workspace_key);
|
||||
}
|
||||
navigator.selected = None;
|
||||
navigator.scroll = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn workspace_action_id(&self) -> Option<String> {
|
||||
self.navigate_workspace_id
|
||||
.as_ref()
|
||||
@@ -676,6 +679,11 @@ impl ClientShellState {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if matches!(code, KeyCode::Left | KeyCode::Right) && modifiers.is_empty() {
|
||||
self.move_navigator_workspace(code == KeyCode::Right);
|
||||
outcome.repaint = true;
|
||||
return;
|
||||
}
|
||||
if code == KeyCode::Backspace && modifiers.is_empty() {
|
||||
if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() {
|
||||
if navigator.filter.take().is_some() {
|
||||
@@ -762,11 +770,6 @@ impl ClientShellState {
|
||||
outcome.repaint = true;
|
||||
return;
|
||||
}
|
||||
if code == KeyCode::Char(' ') && modifiers.is_empty() {
|
||||
self.toggle_selected_navigator_workspace();
|
||||
outcome.repaint = true;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+167
-86
@@ -13,6 +13,8 @@ pub(crate) struct OverlayRender {
|
||||
pub(crate) navigator_popup: Rect,
|
||||
pub(crate) navigator_search: Rect,
|
||||
pub(crate) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>,
|
||||
pub(crate) navigator_scrollbar: Rect,
|
||||
pub(crate) navigator_scroll_metrics: Option<crate::pane::ScrollMetrics>,
|
||||
pub(crate) worktree_search: Rect,
|
||||
pub(crate) worktree_rows: Vec<(Rect, usize)>,
|
||||
pub(crate) help_popup: Rect,
|
||||
@@ -685,24 +687,6 @@ 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,
|
||||
@@ -711,16 +695,27 @@ fn render_navigator_overlay(
|
||||
p: &Palette,
|
||||
) -> Option<OverlayRender> {
|
||||
let a = b.area;
|
||||
let mx = (a.width / 16).max(2);
|
||||
let my = (a.height / 10).max(1);
|
||||
let width = a.width.saturating_sub(4).min(116);
|
||||
let height = a.height.saturating_sub(2).min(42);
|
||||
if width < 4 || height < 9 {
|
||||
return None;
|
||||
}
|
||||
let q = Rect::new(
|
||||
a.x + mx,
|
||||
a.y + my,
|
||||
a.width.saturating_sub(mx * 2).max(4),
|
||||
a.height.saturating_sub(my * 2).max(4),
|
||||
a.x + (a.width - width) / 2,
|
||||
a.y + (a.height - height) / 2,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
.intersection(a);
|
||||
let i = panel(b, q, p.accent, p.panel_bg)?;
|
||||
put_text(
|
||||
b,
|
||||
q.x + 2,
|
||||
q.y,
|
||||
q.width.saturating_sub(4),
|
||||
" Go to ",
|
||||
Style::default().fg(p.accent).bg(p.panel_bg),
|
||||
);
|
||||
let rows = super::aggregate_navigation::navigator_rows(endpoints, active_endpoint_id, n);
|
||||
let search = if n.search_focused {
|
||||
" / ".to_owned()
|
||||
@@ -735,26 +730,32 @@ fn render_navigator_overlay(
|
||||
}
|
||||
)
|
||||
} else if n.query.is_empty() {
|
||||
" / search panes".to_owned()
|
||||
" / search agents and terminals".to_owned()
|
||||
} else {
|
||||
format!(" / {}", n.query)
|
||||
};
|
||||
let terminal_count = rows
|
||||
.iter()
|
||||
.filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
|
||||
.count();
|
||||
let count = format!(
|
||||
"{terminal_count} {}",
|
||||
if terminal_count == 1 {
|
||||
"terminal"
|
||||
} else {
|
||||
"terminals"
|
||||
}
|
||||
);
|
||||
put_text(
|
||||
b,
|
||||
i.x,
|
||||
i.y,
|
||||
i.width,
|
||||
i.width.saturating_sub(display_width(&count) + 1),
|
||||
&search,
|
||||
Style::default()
|
||||
.fg(if n.search_focused { p.text } else { p.overlay0 })
|
||||
.bg(p.panel_bg),
|
||||
);
|
||||
let count = format!(
|
||||
"{} panes",
|
||||
rows.iter()
|
||||
.filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
|
||||
.count()
|
||||
);
|
||||
let cursor = if n.search_focused {
|
||||
text_editor::render(
|
||||
b,
|
||||
@@ -785,7 +786,7 @@ fn render_navigator_overlay(
|
||||
&"─".repeat(i.width as usize),
|
||||
Style::default().fg(p.surface1).bg(p.panel_bg),
|
||||
);
|
||||
let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(4));
|
||||
let body = Rect::new(i.x, i.y + 2, i.width, i.height.saturating_sub(5));
|
||||
let selected = super::aggregate_navigation::navigator_selected_index(&rows, n).unwrap_or(0);
|
||||
let max = rows.len().saturating_sub(body.height as usize);
|
||||
let scroll = n
|
||||
@@ -793,17 +794,32 @@ 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 metrics = crate::pane::ScrollMetrics {
|
||||
offset_from_bottom: max.saturating_sub(scroll),
|
||||
max_offset_from_bottom: max,
|
||||
viewport_rows: usize::from(body.height),
|
||||
};
|
||||
let scrollbar =
|
||||
(max > 0 && body.width > 1).then_some(Rect::new(body.right() - 1, body.y, 1, body.height));
|
||||
let row_width = body.width.saturating_sub(u16::from(scrollbar.is_some()));
|
||||
let mut row_hits = Vec::new();
|
||||
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);
|
||||
if rows.is_empty() {
|
||||
put_text(
|
||||
b,
|
||||
body.x,
|
||||
body.y,
|
||||
body.width,
|
||||
" No matching agents or terminals",
|
||||
Style::default().fg(p.overlay0).bg(p.panel_bg),
|
||||
);
|
||||
}
|
||||
for (ix, r) in rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(scroll)
|
||||
.take(body.height as usize)
|
||||
{
|
||||
let rect = Rect::new(body.x, body.y + (ix - scroll) as u16, row_width, 1);
|
||||
row_hits.push((rect, r.target.clone()));
|
||||
let st = if r.stale {
|
||||
Style::default()
|
||||
@@ -821,50 +837,74 @@ fn render_navigator_overlay(
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(if r.current { p.text } else { p.subtext0 })
|
||||
.fg(
|
||||
if matches!(r.target, ClientNavigatorTarget::Machine { .. }) {
|
||||
p.subtext0
|
||||
} else {
|
||||
p.text
|
||||
},
|
||||
)
|
||||
.bg(p.panel_bg)
|
||||
};
|
||||
b.set_style(rect, st);
|
||||
let tree = match &r.target {
|
||||
ClientNavigatorTarget::Machine { .. } => "▾".to_owned(),
|
||||
ClientNavigatorTarget::Workspace {
|
||||
endpoint_id,
|
||||
workspace_id,
|
||||
} if n
|
||||
.expanded_workspaces
|
||||
.contains(&(endpoint_id.clone(), workspace_id.clone())) =>
|
||||
{
|
||||
if r.depth == 0 { "▾" } else { " ▾" }.to_owned()
|
||||
}
|
||||
ClientNavigatorTarget::Workspace { .. } => {
|
||||
if r.depth == 0 { "▸" } else { " ▸" }.to_owned()
|
||||
}
|
||||
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 { " " });
|
||||
}
|
||||
prefix.push_str(if following_siblings[ix] {
|
||||
"├──"
|
||||
} else {
|
||||
"└──"
|
||||
});
|
||||
prefix
|
||||
}
|
||||
let is_pane = matches!(r.target, ClientNavigatorTarget::Pane { .. });
|
||||
let connector = if !is_pane {
|
||||
""
|
||||
} else if rows
|
||||
.get(ix + 1)
|
||||
.is_some_and(|next| matches!(next.target, ClientNavigatorTarget::Pane { .. }))
|
||||
{
|
||||
"├─ "
|
||||
} else {
|
||||
"└─ "
|
||||
};
|
||||
let padding = u16::from(r.depth.saturating_sub(u8::from(is_pane))) * 2 + 1;
|
||||
let connector_x = rect.x + padding;
|
||||
let indent = format!("{:width$}{connector}", "", width = usize::from(padding));
|
||||
let current = if r.current { "◆ " } else { "" };
|
||||
let status = r.status.map(status_dot).unwrap_or_default();
|
||||
let status_separator = if status.is_empty() { "" } else { " " };
|
||||
let label = format!(" {tree} {current}{status}{status_separator}{}", r.label);
|
||||
put_text(b, rect.x, rect.y, rect.width, &label, st);
|
||||
let label = format!("{indent}{current}{status}{status_separator}{}", r.label);
|
||||
let st = if r.status.is_none() {
|
||||
st.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
st
|
||||
};
|
||||
b.set_style(rect, st);
|
||||
let columns = if r.status.is_some() {
|
||||
if rect.width >= 64 {
|
||||
24
|
||||
} else if rect.width >= 36 {
|
||||
12
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
put_text(
|
||||
b,
|
||||
rect.x,
|
||||
rect.y,
|
||||
rect.width.saturating_sub(columns),
|
||||
&label,
|
||||
st,
|
||||
);
|
||||
if is_pane {
|
||||
put_text(
|
||||
b,
|
||||
connector_x,
|
||||
rect.y,
|
||||
rect.right().saturating_sub(connector_x).min(2),
|
||||
connector,
|
||||
if r.stale || ix == selected {
|
||||
st
|
||||
} else {
|
||||
st.fg(p.overlay0)
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(status) = r.status {
|
||||
let prefix = format!(" {tree} {current}");
|
||||
let prefix = format!("{indent}{current}");
|
||||
let status_style = if r.stale || ix == selected {
|
||||
st
|
||||
} else {
|
||||
@@ -878,6 +918,35 @@ fn render_navigator_overlay(
|
||||
status_dot(status),
|
||||
status_style,
|
||||
);
|
||||
let meta_style = if r.stale || ix == selected {
|
||||
st
|
||||
} else {
|
||||
st.fg(p.overlay0)
|
||||
};
|
||||
if columns > 0 {
|
||||
put_text(
|
||||
b,
|
||||
rect.right() - columns + 1,
|
||||
rect.y,
|
||||
11,
|
||||
r.agent.as_deref().unwrap_or("terminal"),
|
||||
meta_style,
|
||||
);
|
||||
}
|
||||
if columns == 24 {
|
||||
put_text(
|
||||
b,
|
||||
rect.right() - 11,
|
||||
rect.y,
|
||||
11,
|
||||
if r.agent.is_some() {
|
||||
status_text(status)
|
||||
} else {
|
||||
"shell"
|
||||
},
|
||||
meta_style,
|
||||
);
|
||||
}
|
||||
}
|
||||
let machine_status = match &r.target {
|
||||
ClientNavigatorTarget::Machine { endpoint_id } if !endpoint_id.is_local() => endpoints
|
||||
@@ -906,7 +975,7 @@ fn render_navigator_overlay(
|
||||
})
|
||||
};
|
||||
put_right_text(b, rect, rect.y, &signal, signal_style);
|
||||
} else if !r.meta.is_empty() {
|
||||
} else if r.status.is_none() && !r.meta.is_empty() {
|
||||
let label_width = display_width(&label).min(rect.width);
|
||||
let meta = Rect::new(
|
||||
rect.x.saturating_add(label_width).saturating_add(1),
|
||||
@@ -917,16 +986,26 @@ fn render_navigator_overlay(
|
||||
put_right_text(b, meta, rect.y, &r.meta, st)
|
||||
}
|
||||
}
|
||||
let dy = i.bottom() - 2;
|
||||
if let Some(track) = scrollbar {
|
||||
crate::ui::render_scrollbar_buffer(b, metrics, track, p.overlay0, p.overlay1, "▐");
|
||||
}
|
||||
if let Some(r) = rows.get(selected) {
|
||||
put_text(
|
||||
b,
|
||||
i.x,
|
||||
dy,
|
||||
i.bottom() - 3,
|
||||
i.width,
|
||||
&format!(" {} · {}", r.label, r.meta),
|
||||
&format!(" {}", r.detail),
|
||||
Style::default().fg(p.subtext0).bg(p.panel_bg),
|
||||
);
|
||||
put_text(
|
||||
b,
|
||||
i.x,
|
||||
i.bottom() - 2,
|
||||
i.width,
|
||||
&format!(" {}", r.meta),
|
||||
Style::default().fg(p.overlay0).bg(p.panel_bg),
|
||||
)
|
||||
);
|
||||
}
|
||||
put_text(
|
||||
b,
|
||||
@@ -936,7 +1015,7 @@ fn render_navigator_overlay(
|
||||
if n.search_focused {
|
||||
" search type · move ↑↓/ctrl+n/p · open enter · back esc"
|
||||
} else {
|
||||
" move j/k · expand space · filter a/b/w/i/d · search / · open enter · close esc"
|
||||
" ↑↓/j/k rows · ←→ workspace · / search · a/b/w/i/d filter · enter open · esc close"
|
||||
},
|
||||
Style::default().fg(p.overlay0).bg(p.panel_bg),
|
||||
);
|
||||
@@ -948,6 +1027,8 @@ fn render_navigator_overlay(
|
||||
navigator_popup: q,
|
||||
navigator_search: Rect::new(i.x, i.y, i.width, 1),
|
||||
navigator_rows: row_hits,
|
||||
navigator_scrollbar: scrollbar.unwrap_or_default(),
|
||||
navigator_scroll_metrics: Some(metrics),
|
||||
worktree_search: Rect::default(),
|
||||
worktree_rows: Vec::new(),
|
||||
cursor,
|
||||
|
||||
@@ -121,6 +121,8 @@ pub(super) struct ShellHitMap {
|
||||
pub(super) navigator_popup: Rect,
|
||||
pub(super) navigator_search: Rect,
|
||||
pub(super) navigator_rows: Vec<(Rect, ClientNavigatorTarget)>,
|
||||
pub(super) navigator_scrollbar: Rect,
|
||||
pub(super) navigator_scroll_metrics: Option<crate::pane::ScrollMetrics>,
|
||||
pub(super) worktree_search: Rect,
|
||||
pub(super) worktree_rows: Vec<(Rect, usize)>,
|
||||
pub(super) help_popup: Rect,
|
||||
@@ -196,6 +198,9 @@ pub(super) enum ClientChromeDrag {
|
||||
HelpScrollbar {
|
||||
grab_row_offset: u16,
|
||||
},
|
||||
NavigatorScrollbar {
|
||||
grab_row_offset: u16,
|
||||
},
|
||||
ProductAnnouncementScrollbar {
|
||||
grab_row_offset: u16,
|
||||
},
|
||||
@@ -336,10 +341,6 @@ pub(super) enum ClientNavigatorTarget {
|
||||
endpoint_id: ClientEndpointId,
|
||||
workspace_id: String,
|
||||
},
|
||||
Tab {
|
||||
endpoint_id: ClientEndpointId,
|
||||
tab_id: String,
|
||||
},
|
||||
Pane {
|
||||
endpoint_id: ClientEndpointId,
|
||||
pane_id: String,
|
||||
@@ -351,6 +352,8 @@ pub(super) struct ClientNavigatorRow {
|
||||
pub(super) depth: u8,
|
||||
pub(super) label: String,
|
||||
pub(super) meta: String,
|
||||
pub(super) detail: String,
|
||||
pub(super) agent: Option<String>,
|
||||
pub(super) status: Option<crate::api::schema::AgentStatus>,
|
||||
pub(super) stale: bool,
|
||||
pub(super) current: bool,
|
||||
@@ -364,7 +367,6 @@ pub(super) struct ClientNavigatorOverlay {
|
||||
pub(super) selected: Option<ClientNavigatorTarget>,
|
||||
pub(super) scroll: usize,
|
||||
pub(super) filter: Option<ClientNavigatorFilter>,
|
||||
pub(super) expanded_workspaces: HashSet<(ClientEndpointId, String)>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
+520
-32
@@ -940,7 +940,34 @@ fn copy_search_owns_prompt_repeat_highlights_selection_and_restore() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigator_renders_connected_siblings_and_ancestor_lines() {
|
||||
fn navigator_workspace_headings_use_the_active_themes_primary_text() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.config.theme_runtime.auto_switch = false;
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
state.open_navigator_overlay();
|
||||
for palette in [
|
||||
Palette::catppuccin(),
|
||||
Palette::catppuccin_latte(),
|
||||
Palette::terminal(),
|
||||
] {
|
||||
state.config.palette = palette;
|
||||
let frame = state.compose(106, 30).expect("navigator");
|
||||
let (rect, _) = state
|
||||
.hits
|
||||
.navigator_rows
|
||||
.iter()
|
||||
.find(|(_, target)| matches!(target, ClientNavigatorTarget::Workspace { .. }))
|
||||
.expect("workspace heading");
|
||||
let position = cell_symbol_position(&frame, *rect, "client-shell");
|
||||
let buffer = frame.to_ratatui_buffer().expect("buffer");
|
||||
assert_eq!(buffer[position].fg, state.config.palette.text);
|
||||
assert!(buffer[position].modifier.contains(Modifier::BOLD));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigator_renders_every_terminal_in_workspace_sections() {
|
||||
let mut snapshot = snapshot();
|
||||
snapshot.focused_pane_id = None;
|
||||
snapshot.tabs[0].label = "editor".into();
|
||||
@@ -986,39 +1013,46 @@ fn navigator_renders_connected_siblings_and_ancestor_lines() {
|
||||
state.set_snapshot(Box::new(snapshot));
|
||||
state.set_pane_surface(surface());
|
||||
state.open_navigator_overlay();
|
||||
let prefixes = |state: &mut ClientShellState, height| {
|
||||
let visible_rows = |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..]
|
||||
frame.cells[rect.y as usize * frame.width as usize + rect.x as usize..]
|
||||
.iter()
|
||||
.take(6)
|
||||
.take(rect.width as usize)
|
||||
.map(|cell| cell.symbol.as_str())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
prefixes(&mut state, 30),
|
||||
[
|
||||
"▾ clie",
|
||||
"├── ed",
|
||||
"│ ├──",
|
||||
"│ └──",
|
||||
"├── no",
|
||||
"│ └──",
|
||||
"└── lo",
|
||||
" └──",
|
||||
"▾ seco",
|
||||
"└── la",
|
||||
" └──"
|
||||
]
|
||||
);
|
||||
let visible = visible_rows(&mut state, 30);
|
||||
assert_eq!(visible.len(), 7);
|
||||
for (row, prefix) in visible
|
||||
.iter()
|
||||
.zip([" client", " ├─ ", " ├─ ", " ├─ ", " └─ ", " second", " └─ "])
|
||||
{
|
||||
assert!(
|
||||
row.starts_with(prefix),
|
||||
"{row:?} should start with {prefix:?}"
|
||||
);
|
||||
}
|
||||
for (row, label) in visible.iter().zip([
|
||||
"client-shell",
|
||||
"editor · agent · 1",
|
||||
"editor · shell · 2",
|
||||
"notes",
|
||||
"logs",
|
||||
"second",
|
||||
"agent",
|
||||
]) {
|
||||
assert!(row.contains(label), "{row:?} should contain {label}");
|
||||
assert!(!row.contains("/repo"));
|
||||
assert!(!row.contains("──"));
|
||||
}
|
||||
|
||||
// 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");
|
||||
};
|
||||
@@ -1027,24 +1061,477 @@ fn navigator_renders_connected_siblings_and_ancestor_lines() {
|
||||
endpoint_id: state.active_endpoint_id.clone(),
|
||||
pane_id: "pane_shell".into(),
|
||||
});
|
||||
assert_eq!(
|
||||
prefixes(&mut state, 12),
|
||||
["│ ├──", "│ └──", "├── no", "│ └──"]
|
||||
);
|
||||
let visible = visible_rows(&mut state, 11);
|
||||
assert_eq!(visible.len(), 2);
|
||||
assert!(visible.iter().any(|row| row.contains("editor · shell · 2")));
|
||||
assert!(visible[0].starts_with(" ├─ "));
|
||||
assert!(visible[1].starts_with(" ├─ "));
|
||||
|
||||
// Excluded siblings must not leave dangling continuation lines.
|
||||
// Filtering retains the section and the exact split destination.
|
||||
let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
|
||||
panic!("expected navigator");
|
||||
};
|
||||
navigator.query = "shell".into();
|
||||
navigator.query = "pane_shell".into();
|
||||
navigator.scroll = 0;
|
||||
assert_eq!(prefixes(&mut state, 30), ["▾ clie", "└── ed", " └──"]);
|
||||
let visible = visible_rows(&mut state, 30);
|
||||
assert_eq!(visible.len(), 2);
|
||||
assert!(visible[1].starts_with(" └─ "));
|
||||
assert!(visible.iter().any(|row| row.contains("editor · shell · 2")));
|
||||
assert!(visible.iter().all(|row| !row.contains("second")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigator_searches_ancestor_context_and_keeps_split_agents_individually_actionable() {
|
||||
let mut projected = snapshot();
|
||||
projected.tabs[0].label = "review".into();
|
||||
projected.tabs[0].custom_label = true;
|
||||
let mut second = projected.panes[0].clone();
|
||||
second.pane_id = "pane_2".into();
|
||||
second.focused = false;
|
||||
second.foreground_cwd = Some("/repo/subproject".into());
|
||||
projected.panes.push(second);
|
||||
let first_agent = ClientShellAgent {
|
||||
pane_id: "pane_1".into(),
|
||||
workspace_id: "ws_1".into(),
|
||||
tab_id: "tab_1".into(),
|
||||
name: Some("writer".into()),
|
||||
display_agent: None,
|
||||
agent: Some("pi".into()),
|
||||
title: Some("implementing navigation".into()),
|
||||
terminal_title: None,
|
||||
terminal_title_stripped: None,
|
||||
agent_status: AgentStatus::Working,
|
||||
state_change_seq: 1,
|
||||
state_labels: Vec::new(),
|
||||
tokens: Vec::new(),
|
||||
focused: true,
|
||||
};
|
||||
let mut second_agent = first_agent.clone();
|
||||
second_agent.pane_id = "pane_2".into();
|
||||
second_agent.name = Some("reviewer".into());
|
||||
second_agent.agent = Some("claude".into());
|
||||
second_agent.title = Some("checking navigation".into());
|
||||
second_agent.agent_status = AgentStatus::Blocked;
|
||||
second_agent.focused = false;
|
||||
projected.agents = vec![first_agent, second_agent];
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.set_pane_surface(surface());
|
||||
state.open_navigator_overlay();
|
||||
for (query, filter, expected) in [
|
||||
("", None, vec!["pane_1", "pane_2"]),
|
||||
("review", None, vec!["pane_1", "pane_2"]),
|
||||
("client-shell", None, vec!["pane_1", "pane_2"]),
|
||||
("main", None, vec!["pane_1", "pane_2"]),
|
||||
("claude", None, vec!["pane_2"]),
|
||||
("checking navigation", None, vec!["pane_2"]),
|
||||
("/repo/subproject", None, vec!["pane_2"]),
|
||||
(
|
||||
"review",
|
||||
Some(ClientNavigatorFilter::Blocked),
|
||||
vec!["pane_2"],
|
||||
),
|
||||
("", Some(ClientNavigatorFilter::Working), vec!["pane_1"]),
|
||||
("no such agent", None, vec![]),
|
||||
] {
|
||||
let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
|
||||
panic!("navigator");
|
||||
};
|
||||
navigator.query = query.into();
|
||||
navigator.filter = filter;
|
||||
navigator.selected = None;
|
||||
let rows =
|
||||
render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
|
||||
let pane_ids = rows
|
||||
.iter()
|
||||
.filter_map(|row| match &row.target {
|
||||
ClientNavigatorTarget::Pane { pane_id, .. } => Some(pane_id.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(pane_ids, expected, "query={query:?} filter={filter:?}");
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
if expected.is_empty() {
|
||||
0
|
||||
} else {
|
||||
expected.len() + 1
|
||||
}
|
||||
);
|
||||
if !expected.is_empty() {
|
||||
let selected =
|
||||
super::super::aggregate_navigation::navigator_selected_index(&rows, navigator)
|
||||
.expect("search destination");
|
||||
assert!(matches!(
|
||||
rows[selected].target,
|
||||
ClientNavigatorTarget::Pane { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
|
||||
panic!("expected navigator");
|
||||
panic!("navigator");
|
||||
};
|
||||
navigator.query.clear();
|
||||
navigator.expanded_workspaces.clear();
|
||||
assert_eq!(prefixes(&mut state, 30), ["▸ clie", "▸ seco"]);
|
||||
let frame = state.compose(160, 48).expect("navigator");
|
||||
assert_eq!(state.hits.navigator_popup.width, 116);
|
||||
let pane_rows = state
|
||||
.hits
|
||||
.navigator_rows
|
||||
.iter()
|
||||
.filter(|(_, target)| matches!(target, ClientNavigatorTarget::Pane { .. }))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(pane_rows.len(), 2);
|
||||
for ((rect, _), (name, kind, status)) in pane_rows.iter().zip([
|
||||
("writer", "pi", "working"),
|
||||
("reviewer", "claude", "blocked"),
|
||||
]) {
|
||||
cell_symbol_position(&frame, *rect, name);
|
||||
cell_symbol_position(&frame, *rect, kind);
|
||||
cell_symbol_position(&frame, *rect, status);
|
||||
}
|
||||
let rect = pane_rows[1].0;
|
||||
let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: rect.right() - 1,
|
||||
row: rect.y,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(
|
||||
matches!(outcome.actions.as_slice(), [ClientShellAction::Endpoint { request, .. }]
|
||||
if matches!(&request.method, crate::api::schema::Method::PaneFocus(target) if target.pane_id == "pane_2"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigator_distinguishes_unnamed_terminals_on_numbered_tabs() {
|
||||
let mut projected = snapshot();
|
||||
for (number, label) in [(2, "2"), (3, "logs")] {
|
||||
let mut tab = projected.tabs[0].clone();
|
||||
tab.tab_id = format!("tab_{number}");
|
||||
tab.number = number;
|
||||
tab.label = label.into();
|
||||
tab.custom_label = number == 3;
|
||||
tab.focused = false;
|
||||
let mut pane = projected.panes[0].clone();
|
||||
pane.pane_id = format!("pane_{number}");
|
||||
pane.tab_id = tab.tab_id.clone();
|
||||
pane.focused = false;
|
||||
projected.tabs.push(tab);
|
||||
projected.panes.push(pane);
|
||||
}
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.open_navigator_overlay();
|
||||
let Some(ClientShellOverlay::Navigator(navigator)) = &state.overlay else {
|
||||
panic!("navigator");
|
||||
};
|
||||
let rows =
|
||||
render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
|
||||
let labels = rows
|
||||
.iter()
|
||||
.filter(|row| matches!(row.target, ClientNavigatorTarget::Pane { .. }))
|
||||
.map(|row| row.label.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(labels, ["terminal · 1", "terminal · 2", "logs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigator_keeps_empty_workspaces_searchable_without_status_filters() {
|
||||
let mut projected = snapshot();
|
||||
projected.tabs.clear();
|
||||
projected.panes.clear();
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.open_navigator_overlay();
|
||||
for (query, filter, expected) in [
|
||||
("", None, true),
|
||||
("client-shell", None, true),
|
||||
("main", None, true),
|
||||
("missing", None, false),
|
||||
("main", Some(ClientNavigatorFilter::Idle), false),
|
||||
("", Some(ClientNavigatorFilter::Working), false),
|
||||
] {
|
||||
let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
|
||||
panic!("navigator");
|
||||
};
|
||||
navigator.query = query.into();
|
||||
navigator.filter = filter;
|
||||
let rows =
|
||||
render::client_navigator_rows(&state.endpoints, &state.active_endpoint_id, navigator);
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
usize::from(expected),
|
||||
"query={query:?}, filter={filter:?}"
|
||||
);
|
||||
let target =
|
||||
super::super::aggregate_navigation::selected_navigator_target(&rows, navigator);
|
||||
assert_eq!(
|
||||
target,
|
||||
expected.then(|| ClientNavigatorTarget::Workspace {
|
||||
endpoint_id: ClientEndpointId::Local,
|
||||
workspace_id: "ws_1".into(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigator_horizontal_arrows_jump_sections_but_edit_the_search_cursor() {
|
||||
let mut projected = snapshot();
|
||||
projected.panes[0].label = Some("needle-first".into());
|
||||
let mut sibling = projected.panes[0].clone();
|
||||
sibling.pane_id = "pane_sibling".into();
|
||||
sibling.label = Some("other".into());
|
||||
sibling.focused = false;
|
||||
projected.panes.push(sibling);
|
||||
let mut empty = projected.workspaces[0].clone();
|
||||
empty.workspace_id = "ws_empty".into();
|
||||
empty.label = "empty".into();
|
||||
empty.focused = false;
|
||||
projected.workspaces.push(empty);
|
||||
let mut last = projected.workspaces[0].clone();
|
||||
last.workspace_id = "ws_last".into();
|
||||
last.label = "last".into();
|
||||
last.active_tab_id = "tab_last".into();
|
||||
last.focused = false;
|
||||
let mut tab = projected.tabs[0].clone();
|
||||
tab.workspace_id = last.workspace_id.clone();
|
||||
tab.tab_id = last.active_tab_id.clone();
|
||||
tab.focused = false;
|
||||
for (id, label) in [
|
||||
("pane_last", "needle-last"),
|
||||
("pane_last_sibling", "other-last"),
|
||||
] {
|
||||
let mut pane = projected.panes[0].clone();
|
||||
pane.pane_id = id.into();
|
||||
pane.label = Some(label.into());
|
||||
pane.workspace_id = last.workspace_id.clone();
|
||||
pane.tab_id = tab.tab_id.clone();
|
||||
pane.focused = false;
|
||||
projected.panes.push(pane);
|
||||
}
|
||||
projected.workspaces.push(last);
|
||||
projected.tabs.push(tab);
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_snapshot(Box::new(projected));
|
||||
state.set_pane_surface(surface());
|
||||
state.open_navigator_overlay();
|
||||
let press = |state: &mut ClientShellState, code| {
|
||||
let outcome = state.handle_raw_events(vec![RawInputEvent::Key(
|
||||
crate::input::TerminalKey::new(code, KeyModifiers::empty()),
|
||||
)]);
|
||||
assert!(outcome.actions.is_empty());
|
||||
};
|
||||
let selected = |state: &ClientShellState| {
|
||||
let Some(ClientShellOverlay::Navigator(navigator)) = &state.overlay else {
|
||||
panic!("navigator");
|
||||
};
|
||||
navigator.selected.clone()
|
||||
};
|
||||
let target = |id: &str| {
|
||||
Some(ClientNavigatorTarget::Pane {
|
||||
endpoint_id: ClientEndpointId::Local,
|
||||
pane_id: id.into(),
|
||||
})
|
||||
};
|
||||
press(&mut state, KeyCode::Left);
|
||||
assert_eq!(selected(&state), target("pane_1"));
|
||||
press(&mut state, KeyCode::Right);
|
||||
assert_eq!(selected(&state), target("pane_last"));
|
||||
press(&mut state, KeyCode::Down);
|
||||
assert_eq!(selected(&state), target("pane_last_sibling"));
|
||||
press(&mut state, KeyCode::Right);
|
||||
assert_eq!(selected(&state), target("pane_last_sibling"));
|
||||
press(&mut state, KeyCode::Left);
|
||||
assert_eq!(selected(&state), target("pane_1"));
|
||||
let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
|
||||
panic!("navigator");
|
||||
};
|
||||
navigator.query = "needle".into();
|
||||
press(&mut state, KeyCode::Right);
|
||||
assert_eq!(selected(&state), target("pane_last"));
|
||||
press(&mut state, KeyCode::Left);
|
||||
assert_eq!(selected(&state), target("pane_1"));
|
||||
press(&mut state, KeyCode::Char('/'));
|
||||
press(&mut state, KeyCode::Left);
|
||||
press(&mut state, KeyCode::Right);
|
||||
assert_eq!(selected(&state), target("pane_1"));
|
||||
press(&mut state, KeyCode::Left);
|
||||
press(&mut state, KeyCode::Char('X'));
|
||||
let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
|
||||
panic!("navigator");
|
||||
};
|
||||
assert_eq!(navigator.query.as_str(), "needlXe");
|
||||
navigator.search_focused = false;
|
||||
navigator.selected = None;
|
||||
press(&mut state, KeyCode::Left);
|
||||
press(&mut state, KeyCode::Right);
|
||||
assert_eq!(selected(&state), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigator_scrollbar_click_and_drag_scroll_without_opening_a_destination() {
|
||||
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();
|
||||
state.compose(106, 24).expect("small navigator");
|
||||
assert!(state.hits.navigator_scrollbar.is_empty());
|
||||
|
||||
let mut projected = snapshot();
|
||||
for index in 2..=60 {
|
||||
let mut pane = projected.panes[0].clone();
|
||||
pane.pane_id = format!("pane_{index}");
|
||||
pane.label = Some(format!("agent {index}"));
|
||||
pane.focused = false;
|
||||
projected.panes.push(pane);
|
||||
}
|
||||
state.set_snapshot(Box::new(projected));
|
||||
let frame = state.compose(106, 24).expect("overflowing navigator");
|
||||
let track = state.hits.navigator_scrollbar;
|
||||
let metrics = state.hits.navigator_scroll_metrics.expect("scroll metrics");
|
||||
assert!(!track.is_empty());
|
||||
assert_eq!(metrics.offset_from_bottom, metrics.max_offset_from_bottom);
|
||||
assert!(track.y > state.hits.navigator_search.y);
|
||||
assert!(track.bottom() < state.hits.navigator_popup.bottom() - 3);
|
||||
assert!(state
|
||||
.hits
|
||||
.navigator_rows
|
||||
.iter()
|
||||
.all(|(rect, _)| rect.right() == track.x));
|
||||
let buffer = frame.to_ratatui_buffer().expect("buffer");
|
||||
assert_eq!(buffer[(track.x, track.y)].fg, state.config.palette.overlay1);
|
||||
assert_eq!(
|
||||
buffer[(track.x, track.bottom() - 1)].fg,
|
||||
state.config.palette.overlay0
|
||||
);
|
||||
let mouse = |state: &mut ClientShellState, kind, row| {
|
||||
let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
|
||||
kind,
|
||||
column: track.x,
|
||||
row,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
})]);
|
||||
assert!(outcome.actions.is_empty());
|
||||
assert!(matches!(
|
||||
state.overlay,
|
||||
Some(ClientShellOverlay::Navigator(_))
|
||||
));
|
||||
};
|
||||
mouse(
|
||||
&mut state,
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
track.bottom() - 1,
|
||||
);
|
||||
state.compose(106, 24).expect("track jump");
|
||||
assert_eq!(
|
||||
state
|
||||
.hits
|
||||
.navigator_scroll_metrics
|
||||
.expect("metrics")
|
||||
.offset_from_bottom,
|
||||
0
|
||||
);
|
||||
assert!(state.hits.navigator_rows.iter().any(|(_, target)| matches!(target, ClientNavigatorTarget::Pane { pane_id, .. } if pane_id == "pane_60")));
|
||||
mouse(&mut state, MouseEventKind::Down(MouseButton::Left), track.y);
|
||||
state.compose(106, 24).expect("jump back to top");
|
||||
assert_eq!(
|
||||
state
|
||||
.hits
|
||||
.navigator_scroll_metrics
|
||||
.expect("metrics")
|
||||
.offset_from_bottom,
|
||||
metrics.max_offset_from_bottom
|
||||
);
|
||||
let thumb = crate::ui::scrollbar_thumb(metrics, track).expect("thumb");
|
||||
let grab = thumb.len - 1;
|
||||
mouse(
|
||||
&mut state,
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
thumb.top + grab,
|
||||
);
|
||||
assert!(
|
||||
matches!(state.chrome_drag, Some(ClientChromeDrag::NavigatorScrollbar { grab_row_offset }) if grab_row_offset == grab)
|
||||
);
|
||||
mouse(
|
||||
&mut state,
|
||||
MouseEventKind::Drag(MouseButton::Left),
|
||||
thumb.top + grab,
|
||||
);
|
||||
state.compose(106, 24).expect("grab does not move viewport");
|
||||
assert_eq!(
|
||||
state
|
||||
.hits
|
||||
.navigator_scroll_metrics
|
||||
.expect("metrics")
|
||||
.offset_from_bottom,
|
||||
metrics.max_offset_from_bottom
|
||||
);
|
||||
mouse(
|
||||
&mut state,
|
||||
MouseEventKind::Drag(MouseButton::Left),
|
||||
track.bottom() + 5,
|
||||
);
|
||||
state.compose(106, 24).expect("drag to bottom");
|
||||
assert_eq!(
|
||||
state
|
||||
.hits
|
||||
.navigator_scroll_metrics
|
||||
.expect("metrics")
|
||||
.offset_from_bottom,
|
||||
0
|
||||
);
|
||||
mouse(
|
||||
&mut state,
|
||||
MouseEventKind::Up(MouseButton::Left),
|
||||
track.bottom() + 5,
|
||||
);
|
||||
assert!(state.chrome_drag.is_none());
|
||||
state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new(
|
||||
KeyCode::Up,
|
||||
KeyModifiers::empty(),
|
||||
))]);
|
||||
state.compose(106, 24).expect("keyboard resumes after drag");
|
||||
assert_eq!(
|
||||
state
|
||||
.hits
|
||||
.navigator_scroll_metrics
|
||||
.expect("metrics")
|
||||
.offset_from_bottom,
|
||||
1
|
||||
);
|
||||
|
||||
let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
|
||||
panic!("navigator");
|
||||
};
|
||||
navigator.query = "pane_60".into();
|
||||
navigator.selected = None;
|
||||
state.compose(106, 24).expect("filtered navigator");
|
||||
assert!(state.hits.navigator_scrollbar.is_empty());
|
||||
assert_eq!(state.hits.navigator_rows.len(), 2);
|
||||
state.compose(106, 90).expect("tall filtered navigator");
|
||||
assert!(state.hits.navigator_scrollbar.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigator_narrow_layout_and_long_search_stay_inside_the_popup() {
|
||||
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 (width, height) in [(24, 12), (50, 24), (106, 30)] {
|
||||
let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() else {
|
||||
panic!("navigator");
|
||||
};
|
||||
navigator.search_focused = true;
|
||||
navigator.query = "界".repeat(100).as_str().into();
|
||||
let frame = state.compose(width, height).expect("navigator frame");
|
||||
let popup = state.hits.navigator_popup;
|
||||
let cursor = frame.cursor.expect("search cursor");
|
||||
assert!(super::super::contains(popup, (cursor.x, cursor.y)));
|
||||
assert!(state.hits.navigator_rows.is_empty());
|
||||
assert!(popup.right() <= width && popup.bottom() <= height);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1098,7 +1585,8 @@ fn navigator_owns_search_mouse_selection_and_stable_target_focus() {
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(navigator_text.contains("client-shell"));
|
||||
assert!(navigator_text.contains("pane 1"));
|
||||
assert!(navigator_text.contains("terminal"));
|
||||
assert!(!navigator_text.contains("pane 1"));
|
||||
|
||||
let search = state.hits.navigator_search;
|
||||
let focus_search =
|
||||
|
||||
@@ -2056,18 +2056,16 @@ fn navigator_uses_machine_parents_only_for_federated_clients() {
|
||||
assert!(rows.iter().all(|row| match row.target {
|
||||
ClientNavigatorTarget::Machine { .. } => row.depth == 0 && row.status.is_none(),
|
||||
ClientNavigatorTarget::Workspace { .. } => row.depth == 1 && row.status.is_none(),
|
||||
ClientNavigatorTarget::Tab { .. } => row.depth == 2 && row.status.is_none(),
|
||||
ClientNavigatorTarget::Pane { .. } => row.depth == 3 && row.status.is_some(),
|
||||
ClientNavigatorTarget::Pane { .. } => row.depth == 2 && row.status.is_some(),
|
||||
}));
|
||||
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 { .. } => " └── ",
|
||||
ClientNavigatorTarget::Machine { .. } => " ",
|
||||
ClientNavigatorTarget::Workspace { .. } => " ",
|
||||
ClientNavigatorTarget::Pane { .. } => " └─ ",
|
||||
};
|
||||
let prefix = frame.cells[rect.y as usize * frame.width as usize + rect.x as usize..]
|
||||
.iter()
|
||||
@@ -2105,8 +2103,7 @@ fn navigator_uses_machine_parents_only_for_federated_clients() {
|
||||
.all(|row| !matches!(row.target, ClientNavigatorTarget::Machine { .. })));
|
||||
assert!(rows.iter().all(|row| match row.target {
|
||||
ClientNavigatorTarget::Workspace { .. } => row.depth == 0,
|
||||
ClientNavigatorTarget::Tab { .. } => row.depth == 1,
|
||||
ClientNavigatorTarget::Pane { .. } => row.depth == 2,
|
||||
ClientNavigatorTarget::Pane { .. } => row.depth == 1,
|
||||
ClientNavigatorTarget::Machine { .. } => false,
|
||||
}));
|
||||
}
|
||||
@@ -2143,10 +2140,6 @@ fn navigator_keeps_saved_machine_visible_before_metadata_arrives() {
|
||||
endpoint_id: target,
|
||||
..
|
||||
}
|
||||
| ClientNavigatorTarget::Tab {
|
||||
endpoint_id: target,
|
||||
..
|
||||
}
|
||||
| ClientNavigatorTarget::Pane {
|
||||
endpoint_id: target,
|
||||
..
|
||||
@@ -2497,7 +2490,32 @@ fn collapsed_aggregate_workspace_status_uses_its_status_color() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigator_foreign_tab_selection_keeps_the_tab_target() {
|
||||
fn navigator_workspace_arrows_cross_machine_headings_without_activating_them() {
|
||||
let (mut state, endpoint_id) = state_with_remote();
|
||||
state.open_navigator_overlay();
|
||||
for (key, expected_endpoint) in [
|
||||
(KeyCode::Right, endpoint_id),
|
||||
(KeyCode::Left, ClientEndpointId::Local),
|
||||
] {
|
||||
let outcome = state.handle_raw_events(vec![RawInputEvent::Key(
|
||||
crate::input::TerminalKey::new(key, KeyModifiers::empty()),
|
||||
)]);
|
||||
assert!(outcome.actions.is_empty());
|
||||
let Some(ClientShellOverlay::Navigator(navigator)) = &state.overlay else {
|
||||
panic!("navigator");
|
||||
};
|
||||
assert_eq!(
|
||||
navigator.selected,
|
||||
Some(ClientNavigatorTarget::Pane {
|
||||
endpoint_id: expected_endpoint,
|
||||
pane_id: "pane_1".into(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigator_foreign_workspace_heading_keeps_the_workspace_target() {
|
||||
let (mut state, endpoint_id) = state_with_remote();
|
||||
state.open_navigator_overlay();
|
||||
let selected = {
|
||||
@@ -2510,15 +2528,14 @@ fn navigator_foreign_tab_selection_keeps_the_tab_target() {
|
||||
.find(|row| {
|
||||
matches!(
|
||||
&row.target,
|
||||
ClientNavigatorTarget::Tab {
|
||||
ClientNavigatorTarget::Workspace {
|
||||
endpoint_id: target_endpoint,
|
||||
tab_id,
|
||||
..
|
||||
} if target_endpoint == &endpoint_id && tab_id == "tab_1"
|
||||
workspace_id,
|
||||
} if target_endpoint == &endpoint_id && workspace_id == "ws_1"
|
||||
)
|
||||
})
|
||||
.map(|row| row.target.clone())
|
||||
.expect("remote tab row")
|
||||
.expect("remote workspace heading")
|
||||
};
|
||||
if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
|
||||
navigator.selected = Some(selected);
|
||||
@@ -2531,7 +2548,7 @@ fn navigator_foreign_tab_selection_keeps_the_tab_target() {
|
||||
outcome.actions.as_slice(),
|
||||
[ClientShellAction::ActivateEndpoint {
|
||||
endpoint_id: activated,
|
||||
target: Some(ClientEndpointFocusTarget::Tab(tab_id)),
|
||||
}] if activated == &endpoint_id && tab_id == "tab_1"
|
||||
target: Some(ClientEndpointFocusTarget::Workspace(workspace_id)),
|
||||
}] if activated == &endpoint_id && workspace_id == "ws_1"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -230,7 +230,6 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() {
|
||||
selected: None,
|
||||
scroll: 0,
|
||||
filter: None,
|
||||
expanded_workspaces: HashSet::new(),
|
||||
}),
|
||||
ClientShellOverlay::WorktreeCreate(ClientWorktreeCreateOverlay {
|
||||
source_workspace_id: "ws_1".into(),
|
||||
|
||||
@@ -83,7 +83,6 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
|
||||
selected: None,
|
||||
scroll: 0,
|
||||
filter: None,
|
||||
expanded_workspaces: HashSet::new(),
|
||||
}));
|
||||
assert!(!state.modal_paste_target_active());
|
||||
if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
|
||||
|
||||
Reference in New Issue
Block a user