aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/mobile.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-06 02:27:06 +0300
committerGitHub <noreply@github.com>2026-09-06 02:27:06 +0300
commit9e9bc8a144667a6b7debfd4f0c7a31ff0e11493a (patch)
tree03f1259f91fd9edeacfd4b904f2e84d4acf25232 /src/client/shell/mobile.rs
parent35b0dff96579f099e0436290085024424ffb289c (diff)
feat: manage multiple ssh machines from one client (#3670)
Diffstat (limited to 'src/client/shell/mobile.rs')
-rw-r--r--src/client/shell/mobile.rs399
1 files changed, 263 insertions, 136 deletions
diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs
index 014f51a0..abe50590 100644
--- a/src/client/shell/mobile.rs
+++ b/src/client/shell/mobile.rs
@@ -202,6 +202,16 @@ fn render_header_button(
}
}
+fn mobile_endpoint_state(status: ClientEndpointStatus) -> &'static str {
+ match status {
+ ClientEndpointStatus::Connecting => "connecting",
+ ClientEndpointStatus::Online => "online",
+ ClientEndpointStatus::Reconnecting => "reconnecting",
+ ClientEndpointStatus::Attention => "attention",
+ ClientEndpointStatus::Disabled => "disabled",
+ }
+}
+
fn compact_tab_status(snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace) -> String {
let tabs = snapshot
.tabs
@@ -354,6 +364,8 @@ pub(super) fn render_mobile_switcher(
buffer: &mut Buffer,
area: Rect,
snapshot: &ClientShellSnapshot,
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
config: &ClientShellConfig,
selected_workspace_id: Option<&str>,
scroll: &mut usize,
@@ -426,6 +438,8 @@ pub(super) fn render_mobile_switcher(
let items = mobile_items(
snapshot,
+ endpoints,
+ active_endpoint_id,
config,
selected_workspace_id,
viewport.width.saturating_sub(1),
@@ -440,8 +454,10 @@ pub(super) fn render_mobile_switcher(
let end = start.saturating_add(item.lines.len());
if matches!(
item.target.as_ref(),
- Some(ClientMobileTarget::Workspace(workspace_id))
- if workspace_id == selected_workspace_id
+ Some(ClientMobileTarget::Workspace {
+ endpoint_id,
+ workspace_id,
+ }) if endpoint_id == active_endpoint_id && workspace_id == selected_workspace_id
) {
if start < *scroll {
*scroll = start;
@@ -558,22 +574,51 @@ fn render_close_button(buffer: &mut Buffer, area: Rect, palette: &Palette) {
fn mobile_items(
snapshot: &ClientShellSnapshot,
+ endpoints: &[ClientShellEndpoint],
+ active_endpoint_id: &ClientEndpointId,
config: &ClientShellConfig,
selected_workspace_id: Option<&str>,
content_width: u16,
) -> Vec<MobileItem> {
let palette = &config.palette;
let mut items = Vec::new();
- let ordered_agents =
- super::agent_sidebar::ordered_agent_pane_ids(snapshot, config.agent_panel_sort);
- if !ordered_agents.is_empty() || snapshot.agent_view_label.is_some() {
- let title = snapshot
- .agent_view_label
- .as_deref()
+ if endpoints.len() > 1 {
+ items.push(MobileItem::section("machines", palette));
+ for endpoint in endpoints {
+ let background = palette.panel_bg;
+ let (symbol, state, color) = endpoint_status_presentation(endpoint.status, palette);
+ items.push(MobileItem {
+ lines: vec![
+ Line::from(vec![
+ Span::styled(" ", Style::default().bg(background)),
+ Span::styled(symbol, Style::default().fg(color).bg(background)),
+ Span::styled(
+ format!(" {}", endpoint.label),
+ Style::default()
+ .fg(palette.text)
+ .bg(background)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ]),
+ Line::from(Span::styled(
+ format!(" {state}"),
+ Style::default().fg(palette.overlay0).bg(background),
+ )),
+ ],
+ background,
+ target: Some(ClientMobileTarget::Machine(endpoint.endpoint_id.clone())),
+ });
+ }
+ }
+ let agents =
+ super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort);
+ let agent_view_label = snapshot.agent_view_label.as_deref();
+ if !agents.is_empty() || agent_view_label.is_some() {
+ let title = agent_view_label
.map(|label| format!("agents · {label}"))
.unwrap_or_else(|| "agents".to_owned());
items.push(MobileItem::section(title, palette));
- if ordered_agents.is_empty() {
+ if agents.is_empty() {
items.push(MobileItem {
lines: vec![Line::from(Span::styled(
" no matching agents",
@@ -586,19 +631,19 @@ fn mobile_items(
target: None,
});
}
- for pane_id in ordered_agents {
- let Some(agent) = snapshot
- .agents
- .iter()
- .find(|agent| agent.pane_id == pane_id)
- else {
- continue;
- };
- let workspace = snapshot
+ for row in agents {
+ let endpoint = row.endpoint;
+ let agent = row.agent;
+ let workspace = endpoint
+ .snapshot
.workspaces
.iter()
.find(|workspace| workspace.workspace_id == agent.workspace_id);
- let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id);
+ let tab = endpoint
+ .snapshot
+ .tabs
+ .iter()
+ .find(|tab| tab.tab_id == agent.tab_id);
let agent_label = agent
.display_agent
.as_deref()
@@ -609,7 +654,8 @@ fn mobile_items(
.map(|workspace| workspace.label.as_str())
.unwrap_or(agent_label);
let mut detail = Vec::new();
- let workspace_tab_count = snapshot
+ let workspace_tab_count = endpoint
+ .snapshot
.tabs
.iter()
.filter(|candidate| candidate.workspace_id == agent.workspace_id)
@@ -633,11 +679,24 @@ fn mobile_items(
}),
);
detail.push(agent_label.to_owned());
- let background = if agent.focused {
+ if endpoint.stale() {
+ detail.push(mobile_endpoint_state(endpoint.status).to_owned());
+ }
+ let background = if endpoint.endpoint_id == active_endpoint_id && agent.focused {
palette.surface_dim
} else {
palette.panel_bg
};
+ let foreground = if endpoint.stale() {
+ palette.overlay0
+ } else {
+ palette.text
+ };
+ let dim = if endpoint.stale() {
+ Modifier::DIM
+ } else {
+ Modifier::empty()
+ };
items.push(MobileItem {
lines: vec![
Line::from(vec![
@@ -645,19 +704,24 @@ fn mobile_items(
Span::styled(
status_icon(agent.agent_status, config.status_indicators),
Style::default()
- .fg(status_color(agent.agent_status, palette))
- .bg(background),
+ .fg(if endpoint.stale() {
+ palette.overlay0
+ } else {
+ status_color(agent.agent_status, palette)
+ })
+ .bg(background)
+ .add_modifier(dim),
),
Span::styled(" ", Style::default().bg(background)),
Span::styled(
crate::ui::truncate_end(
- primary,
+ &format!("{} · {primary}", endpoint.label),
usize::from(content_width.saturating_sub(5)),
),
Style::default()
- .fg(palette.text)
+ .fg(foreground)
.bg(background)
- .add_modifier(Modifier::BOLD),
+ .add_modifier(Modifier::BOLD | dim),
),
]),
Line::from(Span::styled(
@@ -665,11 +729,17 @@ fn mobile_items(
&format!(" {}", detail.join(" · ")),
usize::from(content_width),
),
- Style::default().fg(palette.overlay0).bg(background),
+ Style::default()
+ .fg(palette.overlay0)
+ .bg(background)
+ .add_modifier(dim),
)),
],
background,
- target: Some(ClientMobileTarget::Agent(agent.pane_id.clone())),
+ target: Some(ClientMobileTarget::Agent {
+ endpoint_id: endpoint.endpoint_id.clone(),
+ pane_id: agent.pane_id.clone(),
+ }),
});
}
}
@@ -680,91 +750,119 @@ fn mobile_items(
ClientMobileTarget::NewWorkspace,
palette,
));
- for entry in super::render::workspace_entries(snapshot, &HashSet::new()) {
- let Some(workspace) = snapshot.workspaces.get(entry.index) else {
- continue;
- };
- let selected = selected_workspace_id == Some(workspace.workspace_id.as_str());
- let background = if selected {
- palette.surface0
- } else if workspace.focused {
- palette.surface_dim
- } else {
- palette.panel_bg
- };
- let connector = if entry.indented {
- if entry.last_child {
- "└─ "
+ for endpoint in super::aggregate_navigation::cached_endpoint_snapshots(endpoints) {
+ for entry in super::render::workspace_entries(endpoint.snapshot, &HashSet::new()) {
+ let Some(workspace) = endpoint.snapshot.workspaces.get(entry.index) else {
+ continue;
+ };
+ let selected = endpoint.endpoint_id == active_endpoint_id
+ && selected_workspace_id == Some(workspace.workspace_id.as_str());
+ let background = if selected {
+ palette.surface0
+ } else if endpoint.endpoint_id == active_endpoint_id && workspace.focused {
+ palette.surface_dim
} else {
- "├─ "
- }
- } else {
- ""
- };
- let name = if entry.indented && !workspace.custom_label {
- workspace
- .branch
- .as_deref()
- .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch)))
- .unwrap_or(&workspace.label)
- } else {
- &workspace.label
- };
- let branch = workspace.branch.as_deref().unwrap_or("shell");
- let detail_prefix = if entry.indented {
- if entry.last_child {
- " "
+ palette.panel_bg
+ };
+ let connector = if entry.indented {
+ if entry.last_child {
+ "└─ "
+ } else {
+ "├─ "
+ }
} else {
- " │ "
- }
- } else {
- " "
- };
- items.push(MobileItem {
- lines: vec![
- Line::from(vec![
- Span::styled(
- format!(" {connector}"),
- Style::default().fg(palette.overlay0).bg(background),
- ),
- Span::styled(
- status_icon(workspace.agent_status, config.status_indicators),
- Style::default()
- .fg(status_color(workspace.agent_status, palette))
- .bg(background),
- ),
- Span::styled(" ", Style::default().bg(background)),
- Span::styled(
+ ""
+ };
+ let name = if entry.indented && !workspace.custom_label {
+ workspace
+ .branch
+ .as_deref()
+ .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch)))
+ .unwrap_or(&workspace.label)
+ } else {
+ &workspace.label
+ };
+ let branch = workspace.branch.as_deref().unwrap_or("shell");
+ let detail_prefix = if entry.indented {
+ if entry.last_child {
+ " "
+ } else {
+ " │ "
+ }
+ } else {
+ " "
+ };
+ let dim = if endpoint.stale() {
+ Modifier::DIM
+ } else {
+ Modifier::empty()
+ };
+ let foreground = if endpoint.stale() {
+ palette.overlay0
+ } else {
+ palette.text
+ };
+ let status = if endpoint.stale() {
+ palette.overlay0
+ } else {
+ status_color(workspace.agent_status, palette)
+ };
+ let stale_detail = if endpoint.stale() {
+ format!(" · {}", mobile_endpoint_state(endpoint.status))
+ } else {
+ String::new()
+ };
+ items.push(MobileItem {
+ lines: vec![
+ Line::from(vec![
+ Span::styled(
+ format!(" {connector}"),
+ Style::default()
+ .fg(palette.overlay0)
+ .bg(background)
+ .add_modifier(dim),
+ ),
+ Span::styled(
+ status_icon(workspace.agent_status, config.status_indicators),
+ Style::default().fg(status).bg(background).add_modifier(dim),
+ ),
+ Span::styled(" ", Style::default().bg(background)),
+ Span::styled(
+ crate::ui::truncate_end(
+ &format!("{} · {name}", endpoint.label),
+ usize::from(content_width.saturating_sub(if entry.indented {
+ 8
+ } else {
+ 5
+ })),
+ ),
+ Style::default()
+ .fg(foreground)
+ .bg(background)
+ .add_modifier(Modifier::BOLD | dim),
+ ),
+ ]),
+ Line::from(Span::styled(
crate::ui::truncate_end(
- name,
- usize::from(content_width.saturating_sub(if entry.indented {
- 8
- } else {
- 5
- })),
+ &format!(
+ "{detail_prefix}{branch} · {}{stale_detail}",
+ compact_tab_status(endpoint.snapshot, workspace)
+ ),
+ usize::from(content_width),
),
Style::default()
- .fg(palette.text)
+ .fg(palette.overlay0)
.bg(background)
- .add_modifier(Modifier::BOLD),
- ),
- ]),
- Line::from(Span::styled(
- crate::ui::truncate_end(
- &format!(
- "{detail_prefix}{branch} · {}",
- compact_tab_status(snapshot, workspace)
- ),
- usize::from(content_width),
- ),
- Style::default().fg(palette.overlay0).bg(background),
- )),
- ],
- background,
- target: Some(ClientMobileTarget::Workspace(
- workspace.workspace_id.clone(),
- )),
- });
+ .add_modifier(dim),
+ )),
+ ],
+ background,
+ target: Some(ClientMobileTarget::Workspace {
+ endpoint_id: endpoint.endpoint_id.clone(),
+ workspace_id: workspace.workspace_id.clone(),
+ }),
+ });
+ }
}
if let Some(workspace_id) = snapshot.focused_workspace_id.as_deref() {
@@ -803,7 +901,10 @@ fn mobile_items(
.add_modifier(Modifier::BOLD),
))],
background,
- target: Some(ClientMobileTarget::Tab(tab.tab_id.clone())),
+ target: Some(ClientMobileTarget::Tab {
+ endpoint_id: active_endpoint_id.clone(),
+ tab_id: tab.tab_id.clone(),
+ }),
});
}
}
@@ -890,6 +991,22 @@ impl ClientShellState {
.find(|(rect, _)| super::contains(*rect, point))
.map(|(_, target)| target.clone());
match target {
+ Some(ClientMobileTarget::Machine(endpoint_id)) => {
+ if endpoint_id == self.active_endpoint_id {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ } else if self.endpoint_is_online(&endpoint_id) {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ outcome.actions.push(ClientShellAction::ActivateEndpoint {
+ endpoint_id,
+ target: None,
+ });
+ } else {
+ let label = self.endpoint_label(&endpoint_id).to_owned();
+ self.receive_endpoint_unavailable(format!("{label} is not ready"));
+ }
+ }
Some(ClientMobileTarget::NewWorkspace) => {
self.mobile_switcher_suspended = true;
self.record_binding(
@@ -897,34 +1014,44 @@ impl ClientShellState {
outcome,
);
}
- Some(
- target @ (ClientMobileTarget::Workspace(_)
- | ClientMobileTarget::Tab(_)
- | ClientMobileTarget::Agent(_)),
- ) => {
- let method = match target {
- ClientMobileTarget::Workspace(workspace_id) => {
- crate::api::schema::Method::WorkspaceFocus(
- crate::api::schema::WorkspaceTarget { workspace_id },
- )
- }
- ClientMobileTarget::Tab(tab_id) => {
- crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget {
- tab_id,
- })
- }
- ClientMobileTarget::Agent(pane_id) => {
- crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget {
- pane_id,
- })
- }
- ClientMobileTarget::NewWorkspace
- | ClientMobileTarget::NewTab
- | ClientMobileTarget::Menu(_) => return true,
- };
- self.mode = ClientShellMode::Terminal;
- self.navigate_workspace_id = None;
- self.push_endpoint_method(method, outcome);
+ Some(ClientMobileTarget::Workspace {
+ endpoint_id,
+ workspace_id,
+ }) => {
+ if self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Workspace(workspace_id),
+ outcome,
+ ) {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ }
+ }
+ Some(ClientMobileTarget::Agent {
+ endpoint_id,
+ pane_id,
+ }) => {
+ if self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Pane(pane_id),
+ outcome,
+ ) {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ }
+ }
+ Some(ClientMobileTarget::Tab {
+ endpoint_id,
+ tab_id,
+ }) => {
+ if self.focus_or_activate(
+ endpoint_id,
+ ClientEndpointFocusTarget::Tab(tab_id),
+ outcome,
+ ) {
+ self.mode = ClientShellMode::Terminal;
+ self.navigate_workspace_id = None;
+ }
}
Some(ClientMobileTarget::NewTab) => {
self.mobile_switcher_suspended = true;